diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -111,6 +111,7 @@
 ### Segmentation
 
 - `watershedSegmentation` - Watershed segmentation
+- `floodFill` - Flood fill with multiple seeds and 4- or 8-connectivity
 
 
 ### Utility
diff --git a/changelog.md b/changelog.md
--- a/changelog.md
+++ b/changelog.md
@@ -3,6 +3,15 @@
 All notable changes to this project will be documented in this file.
 
 
+## 2026-11-07 - 0.3.1.0
+
+- Fix corner ordering for long, skewed documents to prevent quarter-turns
+    during extraction; relabel corners after selecting the final four peaks
+- Fix build with GCC 15, which defaults to C23,
+    whose `bool` keyword can't be parsed by c2hs
+- Add `floodFill` binding
+
+
 ## 2025-01-15 - 0.1
 
 - Initial release of Haskell bindings for FlatCV
diff --git a/flatcv.cabal b/flatcv.cabal
--- a/flatcv.cabal
+++ b/flatcv.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.4
 name:               flatcv
-version:            0.3.0.1
+version:            0.3.1.0
 synopsis:           Haskell bindings for FlatCV image processing library
 description:
     FlatCV is a lightweight, dependency-free image processing library
@@ -43,11 +43,16 @@
 
     build-depends:
         base >= 4.12 && < 5,
+        text >= 1.2 && < 2.2,
         vector >= 0.12 && < 0.14
 
     include-dirs:       .
     c-sources:          flatcv_impl.c
 
+    -- c2hs cannot parse C23's bool keyword (the default since GCC 15).
+    -- Spell it as the equivalent C99 type using a portable preprocessor flag.
+    cpp-options:        -Dbool=_Bool
+
     ghc-options:        -Wall
 
 test-suite flatcv-test
@@ -60,4 +65,5 @@
         base >= 4.12 && < 5,
         flatcv,
         hspec >= 2.7 && < 2.12,
+        text >= 1.2 && < 2.2,
         vector >= 0.12 && < 0.14
diff --git a/flatcv.h b/flatcv.h
--- a/flatcv.h
+++ b/flatcv.h
@@ -271,9 +271,9 @@
 #include <stdint.h>
 
 /**
- * Get EXIF orientation from a JPEG file.
+ * Get EXIF orientation from a JPEG or PNG file.
  *
- * @param filename Path to the JPEG file.
+ * @param filename Path to the JPEG or PNG file.
  * @return Orientation value (1-8), or 1 if not found or error.
  */
 int32_t fcv_get_exif_orientation(char const *filename);
@@ -329,6 +329,24 @@
   uint32_t height,
   uint8_t const * const data
 );
+// File: include/flood_fill.h
+#ifndef FLATCV_AMALGAMATION
+#pragma once
+#include "1_types.h"
+#endif
+
+#include <stdint.h>
+
+uint8_t *fcv_flood_fill(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const grayscale_data,
+  Point2D const *markers,
+  uint32_t num_markers,
+  uint8_t threshold,
+  uint8_t new_value,
+  uint8_t connectivity
+);
 // File: include/foerstner_corner.h
 #ifndef FLATCV_AMALGAMATION
 #pragma once
@@ -406,6 +424,49 @@
 
 Corners fcv_detect_corners(const uint8_t *image, int32_t width, int32_t height);
 Corners* fcv_detect_corners_ptr(const uint8_t *image, int32_t width, int32_t height);
+// File: include/qr_code.h
+#ifndef FLATCV_AMALGAMATION
+#pragma once
+#endif
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "1_types.h"
+#endif
+
+typedef struct {
+  char *text;
+  Corners corners;
+  Point2D finders[3]; // TL, TR, BL finder pattern centers
+  Point2D *alignments;     // Alignment pattern centers (pixel coords)
+  size_t alignment_count;  // Number of alignment patterns (0 for v1)
+  int version;             // QR version, 1..40
+  int qr_size;             // Module count per side: 21 + 4*(version-1)
+  double module_size;
+} FCVQRCode;
+
+typedef struct {
+  FCVQRCode *codes;
+  size_t count;
+} FCVQRCodeResult;
+
+/**
+ * Decode QR codes from a single-channel grayscale image.
+ *
+ * @param width Image width in pixels.
+ * @param height Image height in pixels.
+ * @param gray_pixels Grayscale pixel array (0 = black, 255 = white).
+ * @return Result struct with decoded codes. Call fcv_free_qr_result to release.
+ */
+FCVQRCodeResult fcv_decode_qr_codes(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const gray_pixels
+);
+
+void fcv_free_qr_result(FCVQRCodeResult result);
 // File: include/rgba_to_grayscale.h
 #ifndef FLATCV_AMALGAMATION
 #pragma once
diff --git a/flatcv_impl.c b/flatcv_impl.c
--- a/flatcv_impl.c
+++ b/flatcv_impl.c
@@ -1622,2732 +1622,9077 @@
                 .bl_y = sorted_result[3 % peaks->count].y * scale_y};
   }
 
-  free(sorted_result);
-
-#ifdef DEBUG_LOGGING
-  // Print peaks for debugging
-  fprintf(stderr, "Peaks:\n");
-  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.tl_x, sorted_corners.tl_y);
-  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.tr_x, sorted_corners.tr_y);
-  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.br_x, sorted_corners.br_y);
-  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.bl_x, sorted_corners.bl_y);
-  // Draw corner peaks on the grayscale and resized image
-  for (uint32_t i = 0; i < peaks->count; i++) {
-    // Print peak coordinates for debugging
-    fprintf(
-      stderr,
-      "Peak %u: (%.1f, %.1f)\n",
-      i,
-      peaks->points[i].x,
-      peaks->points[i].y
-    );
-    fcv_draw_circle(
-      out_width,
-      out_height,
-      4,        // GRAY as RGBA (each value is the same)
-      "FFFF00", // Yellow color for corners
-      2,        // Radius
-      peaks->points[i].x,
-      peaks->points[i].y,
-      (uint8_t *)resized_image
-    );
-  }
-
-  // Draw sorted corners with different colors to distinguish from peaks.
-  // Reuse the scale variables from above.
-  // (Inverted for drawing on resized image.)
-  double draw_scale_x = (double)out_width / width;
-  double draw_scale_y = (double)out_height / height;
-
-  // Draw top-left corner in red
-  fcv_draw_circle(
-    out_width,
-    out_height,
-    4,
-    "FF0000", // Red color for top-left
-    3,        // Slightly larger radius to distinguish from peaks
-    sorted_corners.tl_x * draw_scale_x,
-    sorted_corners.tl_y * draw_scale_y,
-    (uint8_t *)resized_image
-  );
-
-  // Draw top-right corner in green
-  fcv_draw_circle(
-    out_width,
-    out_height,
-    4,
-    "00FF00", // Green color for top-right
-    3,
-    sorted_corners.tr_x * draw_scale_x,
-    sorted_corners.tr_y * draw_scale_y,
-    (uint8_t *)resized_image
-  );
-
-  // Draw bottom-right corner in blue
-  fcv_draw_circle(
-    out_width,
-    out_height,
-    4,
-    "0000FF", // Blue color for bottom-right
-    3,
-    sorted_corners.br_x * draw_scale_x,
-    sorted_corners.br_y * draw_scale_y,
-    (uint8_t *)resized_image
-  );
-
-  // Draw bottom-left corner in magenta
-  fcv_draw_circle(
-    out_width,
-    out_height,
-    4,
-    "FF00FF", // Magenta color for bottom-left
-    3,
-    sorted_corners.bl_x * draw_scale_x,
-    sorted_corners.bl_y * draw_scale_y,
-    (uint8_t *)resized_image
-  );
-
-  Image corners_img = {
-    .width = out_width,
-    .height = out_height,
-    .channels = 4,
-    .data = resized_image
-  };
-  write_debug_img(corners_img, "temp_7_corners.png");
-  free((void *)resized_image);
-#endif
-
-  free(peaks);
-  return sorted_corners;
-}
-
-/**
- * Wrapper function that allocates corners on heap for FFI.
- */
-Corners *
-fcv_detect_corners_ptr(const uint8_t *image, int32_t width, int32_t height) {
-  Corners corners = fcv_detect_corners(image, width, height);
-  Corners *result = malloc(sizeof(Corners));
-  if (result == NULL) {
-    return NULL;
-  }
-  *result = corners;
-  return result;
-}
-// File: src/corner_peaks.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "1_types.h"
-#include "conversion.h"
-#include "corner_peaks.h"
-#else
-#include "flatcv.h"
-#endif
-
-static bool is_local_maximum(
-  uint8_t const *data,
-  uint32_t width,
-  uint32_t height,
-  uint32_t x,
-  uint32_t y,
-  uint32_t channel
-) {
-  if (x == 0 || y == 0 || x >= width - 1 || y >= height - 1) {
-    return false;
-  }
-
-  uint32_t center_idx = (y * width + x) * 2 + channel;
-  uint8_t center_val = data[center_idx];
-
-  if (center_val == 0) {
-    return false;
-  }
-
-  for (int32_t dy = -1; dy <= 1; dy++) {
-    for (int32_t dx = -1; dx <= 1; dx++) {
-      if (dx == 0 && dy == 0) {
-        continue;
-      }
-
-      uint32_t neighbor_idx = ((y + dy) * width + (x + dx)) * 2 + channel;
-      if (data[neighbor_idx] > center_val) {
-        return false;
-      }
-    }
-  }
-
-  return true;
-}
-
-static double euclidean_distance(Point2D a, Point2D b) {
-  double dx = a.x - b.x;
-  double dy = a.y - b.y;
-  return sqrt(dx * dx + dy * dy);
-}
-
-/** Detect corner peaks in the image.
- * This function finds local maxima in the corner response image
- * and filters them based on specified thresholds.
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param data Pointer to the 2 channel (w and q) corner response data.
- * @param min_distance Minimum distance between peaks.
- * @param accuracy_thresh Threshold for accuracy measure (w).
- * @param roundness_thresh Threshold for roundness measure (q).
- * @return Pointer to CornerPeaks structure containing detected peaks.
- */
-CornerPeaks *fcv_corner_peaks(
-  uint32_t width,
-  uint32_t height,
-  uint8_t const *data,
-  uint32_t min_distance,
-  double accuracy_thresh,
-  double roundness_thresh
-) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Validate thresholds
-  if (!isfinite(accuracy_thresh) || !isfinite(roundness_thresh)) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-
-  // Check for overflow in candidates allocation
-  if (num_pixels > SIZE_MAX / sizeof(Point2D)) {
-    return NULL;
-  }
-
-  Point2D *candidates = malloc(sizeof(Point2D) * num_pixels);
-  if (!candidates) {
-    return NULL;
-  }
-
-  uint32_t candidate_count = 0;
-
-  for (uint32_t y = 1; y < height - 1; y++) {
-    for (uint32_t x = 1; x < width - 1; x++) {
-      uint32_t idx = (y * width + x) * 2;
-
-      // Convert normalized values (0-255) back to 0-1 range
-      double w = data[idx] / 255.0;     // accuracy measure
-      double q = data[idx + 1] / 255.0; // roundness measure
-
-      if (q > roundness_thresh && w > accuracy_thresh &&
-          is_local_maximum(data, width, height, x, y, 0)) {
-        candidates[candidate_count].x = (double)x;
-        candidates[candidate_count].y = (double)y;
-        candidate_count++;
-      }
-    }
-  }
-
-  if (candidate_count == 0) {
-    free(candidates);
-    CornerPeaks *result = malloc(sizeof(CornerPeaks));
-    if (result) {
-      result->points = NULL;
-      result->count = 0;
-    }
-    return result;
-  }
-
-  bool *rejected = calloc(candidate_count, sizeof(bool));
-  if (!rejected) {
-    free(candidates);
-    return NULL;
-  }
-
-  for (uint32_t i = 0; i < candidate_count; i++) {
-    if (rejected[i]) {
-      continue;
-    }
-
-    for (uint32_t j = i + 1; j < candidate_count; j++) {
-      if (rejected[j]) {
-        continue;
-      }
-
-      if (euclidean_distance(candidates[i], candidates[j]) < min_distance) {
-        uint32_t i_idx =
-          ((uint32_t)candidates[i].y * width + (uint32_t)candidates[i].x) * 2;
-        uint32_t j_idx =
-          ((uint32_t)candidates[j].y * width + (uint32_t)candidates[j].x) * 2;
-
-        if (data[i_idx] >= data[j_idx]) {
-          rejected[j] = true;
-        }
-        else {
-          rejected[i] = true;
-          break;
-        }
-      }
-    }
-  }
-
-  uint32_t final_count = 0;
-  for (uint32_t i = 0; i < candidate_count; i++) {
-    if (!rejected[i]) {
-      final_count++;
-    }
-  }
-
-  Point2D *final_points = malloc(sizeof(Point2D) * final_count);
-  if (!final_points) {
-    free(candidates);
-    free(rejected);
-    return NULL;
-  }
-
-  uint32_t idx = 0;
-  for (uint32_t i = 0; i < candidate_count; i++) {
-    if (!rejected[i]) {
-      final_points[idx] = candidates[i];
-      idx++;
-    }
-  }
-
-  free(candidates);
-  free(rejected);
-
-  CornerPeaks *result = malloc(sizeof(CornerPeaks));
-  if (!result) {
-    free(final_points);
-    return NULL;
-  }
-
-  result->points = final_points;
-  result->count = final_count;
-
-  return result;
-}
-// File: src/crop.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "crop.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Crop an image.
- *
- * @param width Width of the original image.
- * @param height Height of the original image.
- * @param channels Number of channels in the image.
- * @param data Pointer to the pixel data.
- * @param x The x-coordinate of the top-left corner of the crop area.
- * @param y The y-coordinate of the top-left corner of the crop area.
- * @param new_width The width of the crop area.
- * @param new_height The height of the crop area.
- * @return Pointer to the new cropped image data.
- */
-uint8_t *fcv_crop(
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  uint8_t const *const data,
-  uint32_t x,
-  uint32_t y,
-  uint32_t new_width,
-  uint32_t new_height
-) {
-  if (!data) {
-    return NULL;
-  }
-
-  if (width == 0 || height == 0 || channels == 0 || new_width == 0 ||
-      new_height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow in x + new_width and y + new_height
-  if (x > UINT32_MAX - new_width || y > UINT32_MAX - new_height) {
-    return NULL;
-  }
-
-  if (x + new_width > width || y + new_height > height) {
-    fprintf(stderr, "Crop area is outside the original image bounds.\n");
-    return NULL;
-  }
-
-  // Check for overflow in memory allocation: new_width * new_height * channels
-  if (new_width > SIZE_MAX / new_height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)new_width * new_height;
-  if (num_pixels > SIZE_MAX / channels) {
-    return NULL;
-  }
-  size_t alloc_size = num_pixels * channels;
-
-  uint8_t *cropped_data = malloc(alloc_size);
-  if (!cropped_data) {
-    fprintf(stderr, "Memory allocation failed for cropped image.\n");
-    return NULL;
-  }
-
-  size_t row_bytes = (size_t)new_width * channels;
-  for (uint32_t i = 0; i < new_height; ++i) {
-    size_t src_index = ((size_t)(y + i) * width + x) * channels;
-    size_t dst_index = (size_t)i * row_bytes;
-    memcpy(cropped_data + dst_index, data + src_index, row_bytes);
-  }
-
-  return cropped_data;
-}
-// File: src/draw.c
-#include <assert.h>
-#include <limits.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "draw.h"
-#include "parse_hex_color.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Helper function to set a pixel to specified color (for circle drawing)
- */
-void fcv_set_circle_pixel(
-  uint8_t *data,
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  int32_t px,
-  int32_t py,
-  uint8_t r,
-  uint8_t g,
-  uint8_t b
-) {
-  if (!data || width == 0 || height == 0 || channels == 0) {
-    return;
-  }
-  if (px >= 0 && px < (int32_t)width && py >= 0 && py < (int32_t)height) {
-    size_t pixel_index = ((size_t)py * width + px) * channels;
-
-    if (channels == 1) {
-      // Grayscale: use luminance formula
-      data[pixel_index] = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);
-    }
-    else if (channels >= 3) {
-      data[pixel_index] = r;     // R
-      data[pixel_index + 1] = g; // G
-      data[pixel_index + 2] = b; // B
-      if (channels == 4) {
-        data[pixel_index + 3] = 255; // A
-      }
-    }
-  }
-}
-
-/**
- * Helper function to draw 8 symmetric points of a circle
- */
-void fcv_draw_circle_points(
-  uint8_t *data,
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  int32_t cx,
-  int32_t cy,
-  int32_t x,
-  int32_t y,
-  uint8_t r,
-  uint8_t g,
-  uint8_t b
-) {
-  fcv_set_circle_pixel(data, width, height, channels, cx + x, cy + y, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx - x, cy + y, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx + x, cy - y, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx - x, cy - y, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx + y, cy + x, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx - y, cy + x, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx + y, cy - x, r, g, b);
-  fcv_set_circle_pixel(data, width, height, channels, cx - y, cy - x, r, g, b);
-}
-
-/**
- * Helper function to fill horizontal lines for a filled circle (disk)
- */
-void fcv_fill_disk_lines(
-  uint8_t *data,
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  int32_t cx,
-  int32_t cy,
-  int32_t x,
-  int32_t y,
-  uint8_t r,
-  uint8_t g,
-  uint8_t b
-) {
-  // Fill horizontal lines at y offsets
-  for (int32_t i = cx - x; i <= cx + x; i++) {
-    fcv_set_circle_pixel(data, width, height, channels, i, cy + y, r, g, b);
-    fcv_set_circle_pixel(data, width, height, channels, i, cy - y, r, g, b);
-  }
-
-  // Fill horizontal lines at x offsets
-  // (avoid duplicating center line when x == y)
-  if (x != y) {
-    for (int32_t i = cx - y; i <= cx + y; i++) {
-      fcv_set_circle_pixel(data, width, height, channels, i, cy + x, r, g, b);
-      fcv_set_circle_pixel(data, width, height, channels, i, cy - x, r, g, b);
-    }
-  }
-}
-
-/**
- * Draw a circle on an image using Bresenham's circle algorithm
- * (modifies in-place).
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param channels Number of channels in the image (1, 3, or 4).
- * @param hex_color Hex color code (e.g., "FF0000" for red, "#00FF00" for
- * green).
- * @param radius Radius of the circle.
- * @param center_x X coordinate of the circle center.
- * @param center_y Y coordinate of the circle center.
- * @param data Pointer to the pixel data (modified in-place).
- */
-void fcv_draw_circle(
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  const char *hex_color,
-  double radius,
-  double center_x,
-  double center_y,
-  uint8_t *data
-) {
-  if (!data || !hex_color || width == 0 || height == 0 || channels == 0) {
-    return;
-  }
-
-  // Validate radius and center coordinates
-  if (radius < 0 || !isfinite(radius) || !isfinite(center_x) ||
-      !isfinite(center_y)) {
-    return;
-  }
-
-  // Reject excessive radius to prevent integer overflow in Bresenham algorithm
-  if (radius > INT32_MAX / 2) {
-    return;
-  }
-
-  // Parse color
-  uint8_t r, g, b;
-  fcv_parse_hex_color(hex_color, &r, &g, &b);
-
-  int32_t radius_int32_t = (int32_t)radius;
-  int32_t cx = (int32_t)center_x;
-  int32_t cy = (int32_t)center_y;
-
-  // Bresenham's circle algorithm
-  int32_t x = 0;
-  int32_t y = radius_int32_t;
-  int32_t d = 3 - 2 * radius_int32_t;
-
-  // Initial points
-  fcv_draw_circle_points(data, width, height, channels, cx, cy, x, y, r, g, b);
-
-  while (y >= x) {
-    x++;
-    if (d > 0) {
-      y--;
-      d = d + 4 * (x - y) + 10;
-    }
-    else {
-      d = d + 4 * x + 6;
-    }
-    fcv_draw_circle_points(
-      data,
-      width,
-      height,
-      channels,
-      cx,
-      cy,
-      x,
-      y,
-      r,
-      g,
-      b
-    );
-  }
-}
-
-/**
- * Draw a filled circle (disk) on an image using Bresenham's circle
- * algorithm (modifies in-place).
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param channels Number of channels in the image (1, 3, or 4).
- * @param hex_color Hex color code (e.g., "FF0000" for red, "#00FF00" for
- * green).
- * @param radius Radius of the disk.
- * @param center_x X coordinate of the disk center.
- * @param center_y Y coordinate of the disk center.
- * @param data Pointer to the pixel data (modified in-place).
- */
-void fcv_draw_disk(
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  const char *hex_color,
-  double radius,
-  double center_x,
-  double center_y,
-  uint8_t *data
-) {
-  if (!data || !hex_color || width == 0 || height == 0 || channels == 0) {
-    return;
-  }
-
-  // Validate radius and center coordinates
-  if (radius < 0 || !isfinite(radius) || !isfinite(center_x) ||
-      !isfinite(center_y)) {
-    return;
-  }
-
-  // Reject excessive radius to prevent integer overflow in Bresenham algorithm
-  if (radius > INT32_MAX / 2) {
-    return;
-  }
-
-  // Parse color
-  uint8_t r, g, b;
-  fcv_parse_hex_color(hex_color, &r, &g, &b);
-
-  int32_t radius_int32_t = (int32_t)radius;
-  int32_t cx = (int32_t)center_x;
-  int32_t cy = (int32_t)center_y;
-
-  // Bresenham's circle algorithm for filled disk
-  int32_t x = 0;
-  int32_t y = radius_int32_t;
-  int32_t d = 3 - 2 * radius_int32_t;
-
-  // Initial filled lines
-  fcv_fill_disk_lines(data, width, height, channels, cx, cy, x, y, r, g, b);
-
-  while (y >= x) {
-    x++;
-    if (d > 0) {
-      y--;
-      d = d + 4 * (x - y) + 10;
-    }
-    else {
-      d = d + 4 * x + 6;
-    }
-    fcv_fill_disk_lines(data, width, height, channels, cx, cy, x, y, r, g, b);
-  }
-}
-
-/**
- * Add a colored border around an image.
- * Returns a new image with the border added.
- *
- * @param width Width of the input image.
- * @param height Height of the input image.
- * @param channels Number of channels in the image (1, 3, or 4).
- * @param hex_color Hex color code for the border (e.g., "FF0000" for red).
- * @param border_width Width of the border in pixels.
- * @param input_data Pointer to the input pixel data.
- * @param output_width Pointer to store the output image width.
- * @param output_height Pointer to store the output image height.
- * @return Pointer to the new image data with border, or NULL on failure.
- */
-uint8_t *fcv_add_border(
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  const char *hex_color,
-  uint32_t border_width,
-  uint8_t *input_data,
-  uint32_t *output_width,
-  uint32_t *output_height
-) {
-  if (!input_data || !output_width || !output_height || !hex_color ||
-      border_width == 0 || channels == 0) {
-    return NULL;
-  }
-
-  if (width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow in output dimensions
-  if (border_width > (UINT32_MAX - width) / 2 ||
-      border_width > (UINT32_MAX - height) / 2) {
-    return NULL;
-  }
-
-  // Parse border color
-  uint8_t r, g, b;
-  fcv_parse_hex_color(hex_color, &r, &g, &b);
-
-  // Calculate output dimensions
-  *output_width = width + 2 * border_width;
-  *output_height = height + 2 * border_width;
-
-  // Check for overflow in memory allocation: output_width * output_height *
-  // channels
-  if (*output_width > SIZE_MAX / *output_height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)(*output_width) * (*output_height);
-  if (num_pixels > SIZE_MAX / channels) {
-    return NULL;
-  }
-  size_t output_size = num_pixels * channels;
-
-  uint8_t *output_data = malloc(output_size);
-  if (!output_data) {
-    return NULL;
-  }
-
-  // Fill the entire output image with border color
-  for (uint32_t y = 0; y < *output_height; y++) {
-    for (uint32_t x = 0; x < *output_width; x++) {
-      uint32_t pixel_index = (y * *output_width + x) * channels;
-
-      if (channels == 1) {
-        // Grayscale: use luminance formula
-        output_data[pixel_index] = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);
-      }
-      else if (channels >= 3) {
-        output_data[pixel_index] = r;     // R
-        output_data[pixel_index + 1] = g; // G
-        output_data[pixel_index + 2] = b; // B
-        if (channels == 4) {
-          output_data[pixel_index + 3] = 255; // A
-        }
-      }
-    }
-  }
-
-  // Copy the original image into the center
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      uint32_t src_index = (y * width + x) * channels;
-      uint32_t dst_index =
-        ((y + border_width) * *output_width + (x + border_width)) * channels;
-
-      for (uint32_t c = 0; c < channels; c++) {
-        output_data[dst_index + c] = input_data[src_index + c];
-      }
-    }
-  }
-
-  return output_data;
-}
-// File: src/exif.c
-#include <stdint.h>
-#include <stdio.h>
-#include <string.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "exif.h"
-#endif
-
-static uint16_t read_u16(FILE *file, int is_little_endian) {
-  uint8_t buf[2];
-  if (fread(buf, 1, 2, file) != 2) {
-    return 0;
-  }
-  if (is_little_endian) {
-    return (uint16_t)(buf[0] | (buf[1] << 8));
-  }
-  return (uint16_t)((buf[0] << 8) | buf[1]);
-}
-
-static uint32_t read_u32(FILE *file, int is_little_endian) {
-  uint8_t buf[4];
-  if (fread(buf, 1, 4, file) != 4) {
-    return 0;
-  }
-  if (is_little_endian) {
-    return (uint32_t)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
-  }
-  return (uint32_t)((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]);
-}
-
-int32_t fcv_get_exif_orientation(char const *filename) {
-  FILE *file = fopen(filename, "rb");
-  if (!file) {
-    return 1;
-  }
-
-  uint8_t header[2];
-  if (fread(header, 1, 2, file) != 2 || header[0] != 0xFF ||
-      header[1] != 0xD8) {
-    fclose(file);
-    return 1;
-  }
-
-  uint8_t marker[2];
-  while (fread(marker, 1, 2, file) == 2) {
-    if (marker[0] != 0xFF) {
-      break;
-    }
-
-    if (marker[1] == 0xD9 || marker[1] == 0xDA) { // EOI or SOS
-      break;
-    }
-
-    uint16_t size = read_u16(file, 0); // Markers are always big-endian
-
-    if (marker[1] == 0xE1) { // APP1 - EXIF
-      uint8_t exif_header[6];
-      if (fread(exif_header, 1, 6, file) != 6) {
-        break;
-      }
-
-      if (memcmp(exif_header, "Exif\0\0", 6) != 0) {
-        fseek(file, size - 2 - 6, SEEK_CUR);
-        continue;
-      }
-
-      long tiff_start = ftell(file);
-      uint8_t tiff_header[2];
-      if (fread(tiff_header, 1, 2, file) != 2) {
-        break;
-      }
-
-      int is_little_endian = (tiff_header[0] == 'I' && tiff_header[1] == 'I');
-      int is_big_endian = (tiff_header[0] == 'M' && tiff_header[1] == 'M');
-
-      if (!is_little_endian && !is_big_endian) {
-        break;
-      }
-
-      uint16_t magic = read_u16(file, is_little_endian);
-      if (magic != 42) {
-        break;
-      }
-
-      uint32_t ifd_offset = read_u32(file, is_little_endian);
-
-      fseek(file, tiff_start + ifd_offset, SEEK_SET);
-
-      uint16_t num_entries = read_u16(file, is_little_endian);
-
-      for (uint16_t i = 0; i < num_entries; i++) {
-        uint16_t tag = read_u16(file, is_little_endian);
-        (void)read_u16(file, is_little_endian); // Skip type
-        (void)read_u32(file, is_little_endian); // Skip count
-
-        if (tag == 0x0112) { // Orientation
-          uint16_t orientation = read_u16(file, is_little_endian);
-          fclose(file);
-          return (int32_t)orientation;
-        }
-        else {
-          fseek(file, 4, SEEK_CUR); // Skip value/offset
-        }
-      }
-      break; // IFD0 done
-    }
-    else {
-      fseek(file, size - 2, SEEK_CUR);
-    }
-  }
-
-  fclose(file);
-  return 1;
-}
-// File: src/extract_document.c
-#include <math.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "perspectivetransform.h"
-#else
-#include "flatcv.h"
-#endif
-
-uint8_t *fcv_extract_document(
-  uint32_t width,
-  uint32_t height,
-  uint8_t const *const data,
-  uint32_t output_width,
-  uint32_t output_height
-) {
-  if (!data) {
-    fprintf(stderr, "Error: NULL image data\n");
-    return NULL;
-  }
-
-  // Step 1: Detect corners of the document
-  Corners detected_corners = fcv_detect_corners(data, width, height);
-
-  // Step 2: Define destination corners (rectangular output)
-  Corners dst_corners = {
-    .tl_x = 0.0,
-    .tl_y = 0.0,
-    .tr_x = (double)(output_width - 1),
-    .tr_y = 0.0,
-    .br_x = (double)(output_width - 1),
-    .br_y = (double)(output_height - 1),
-    .bl_x = 0.0,
-    .bl_y = (double)(output_height - 1)
-  };
-
-  // Step 3: Calculate perspective transformation matrix
-  // Note: We need to map FROM destination TO source for the inverse transform
-  Matrix3x3 *transform_matrix =
-    fcv_calculate_perspective_transform(&dst_corners, &detected_corners);
-
-  if (!transform_matrix) {
-    fprintf(stderr, "Error: Failed to calculate perspective transform\n");
-    return NULL;
-  }
-
-  // Step 4: Apply the transformation
-  uint8_t *result = fcv_apply_matrix_3x3(
-    width,
-    height,
-    (uint8_t *)data,
-    output_width,
-    output_height,
-    transform_matrix
-  );
-
-  // Free the transformation matrix if it's not the identity matrix
-  if (transform_matrix &&
-      !(transform_matrix->m00 == 1.0 && transform_matrix->m01 == 0.0 &&
-        transform_matrix->m02 == 0.0 && transform_matrix->m10 == 0.0 &&
-        transform_matrix->m11 == 1.0 && transform_matrix->m12 == 0.0 &&
-        transform_matrix->m20 == 0.0 && transform_matrix->m21 == 0.0 &&
-        transform_matrix->m22 == 1.0)) {
-    free(transform_matrix);
-  }
-
-  return result;
-}
-
-uint8_t *fcv_extract_document_auto(
-  uint32_t width,
-  uint32_t height,
-  uint8_t const *const data,
-  uint32_t *output_width,
-  uint32_t *output_height
-) {
-  if (!data || !output_width || !output_height) {
-    fprintf(stderr, "Error: NULL parameters\n");
-    return NULL;
-  }
-
-  // Step 1: Detect corners of the document
-  Corners detected_corners = fcv_detect_corners(data, width, height);
-
-  // Step 2: Calculate optimal output dimensions based on detected corners
-  // Calculate distances between corners to determine aspect ratio
-  double top_width = sqrt(
-    pow(detected_corners.tr_x - detected_corners.tl_x, 2) +
-    pow(detected_corners.tr_y - detected_corners.tl_y, 2)
-  );
-  double bottom_width = sqrt(
-    pow(detected_corners.br_x - detected_corners.bl_x, 2) +
-    pow(detected_corners.br_y - detected_corners.bl_y, 2)
-  );
-  double left_height = sqrt(
-    pow(detected_corners.bl_x - detected_corners.tl_x, 2) +
-    pow(detected_corners.bl_y - detected_corners.tl_y, 2)
-  );
-  double right_height = sqrt(
-    pow(detected_corners.br_x - detected_corners.tr_x, 2) +
-    pow(detected_corners.br_y - detected_corners.tr_y, 2)
-  );
-
-  // Use maximum dimensions to preserve detail
-  double max_width = fmax(top_width, bottom_width);
-  double max_height = fmax(left_height, right_height);
-
-  // Round to reasonable dimensions
-  *output_width = (uint32_t)(max_width + 0.5);
-  *output_height = (uint32_t)(max_height + 0.5);
-
-  // Ensure minimum reasonable size
-  if (*output_width < 100) {
-    *output_width = 100;
-  }
-  if (*output_height < 100) {
-    *output_height = 100;
-  }
-
-  // Step 3: Define destination corners (rectangular output)
-  Corners dst_corners = {
-    .tl_x = 0.0,
-    .tl_y = 0.0,
-    .tr_x = (double)(*output_width - 1),
-    .tr_y = 0.0,
-    .br_x = (double)(*output_width - 1),
-    .br_y = (double)(*output_height - 1),
-    .bl_x = 0.0,
-    .bl_y = (double)(*output_height - 1)
-  };
-
-  // Step 4: Calculate perspective transformation matrix
-  // Note: We need to map FROM destination TO source for the inverse transform
-  Matrix3x3 *transform_matrix =
-    fcv_calculate_perspective_transform(&dst_corners, &detected_corners);
-
-  if (!transform_matrix) {
-    fprintf(stderr, "Error: Failed to calculate perspective transform\n");
-    return NULL;
-  }
-
-  // Step 5: Apply the transformation
-  uint8_t *result = fcv_apply_matrix_3x3(
-    width,
-    height,
-    (uint8_t *)data,
-    *output_width,
-    *output_height,
-    transform_matrix
-  );
-
-  // Free the transformation matrix if it's not the identity matrix
-  if (transform_matrix &&
-      !(transform_matrix->m00 == 1.0 && transform_matrix->m01 == 0.0 &&
-        transform_matrix->m02 == 0.0 && transform_matrix->m10 == 0.0 &&
-        transform_matrix->m11 == 1.0 && transform_matrix->m12 == 0.0 &&
-        transform_matrix->m20 == 0.0 && transform_matrix->m21 == 0.0 &&
-        transform_matrix->m22 == 1.0)) {
-    free(transform_matrix);
-  }
-
-  return result;
-}
-// File: src/flip.c
-#include <stdint.h>
-#include <stdlib.h>
-#include <string.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "flip.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Flip an image horizontally (mirror along vertical axis).
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param data Pointer to the pixel data.
- * @return Pointer to the flipped image data.
- */
-uint8_t *
-fcv_flip_x(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *flipped_data = malloc(img_length_byte);
-
-  if (!flipped_data) { // Memory allocation failed
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index = ((size_t)y * width + (width - 1 - x)) * 4;
-
-      flipped_data[dst_index] = data[src_index];         // R
-      flipped_data[dst_index + 1] = data[src_index + 1]; // G
-      flipped_data[dst_index + 2] = data[src_index + 2]; // B
-      flipped_data[dst_index + 3] = data[src_index + 3]; // A
-    }
-  }
-
-  return flipped_data;
-}
-
-/**
- * Flip an image vertically (mirror along horizontal axis).
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param data Pointer to the pixel data.
- * @return Pointer to the flipped image data.
- */
-uint8_t *
-fcv_flip_y(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *flipped_data = malloc(img_length_byte);
-
-  if (!flipped_data) { // Memory allocation failed
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index = ((size_t)(height - 1 - y) * width + x) * 4;
-
-      flipped_data[dst_index] = data[src_index];         // R
-      flipped_data[dst_index + 1] = data[src_index + 1]; // G
-      flipped_data[dst_index + 2] = data[src_index + 2]; // B
-      flipped_data[dst_index + 3] = data[src_index + 3]; // A
-    }
-  }
-
-  return flipped_data;
-}
-
-/**
- * Transpose an image (flip along main diagonal).
- */
-uint8_t *
-fcv_transpose(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *transposed_data = malloc(img_length_byte);
-
-  if (!transposed_data) {
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index = ((size_t)x * height + y) * 4;
-
-      transposed_data[dst_index] = data[src_index];
-      transposed_data[dst_index + 1] = data[src_index + 1];
-      transposed_data[dst_index + 2] = data[src_index + 2];
-      transposed_data[dst_index + 3] = data[src_index + 3];
-    }
-  }
-
-  return transposed_data;
-}
-
-/**
- * Transverse an image (flip along anti-diagonal).
- */
-uint8_t *
-fcv_transverse(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *transposed_data = malloc(img_length_byte);
-
-  if (!transposed_data) {
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index =
-        ((size_t)(width - 1 - x) * height + (height - 1 - y)) * 4;
-
-      transposed_data[dst_index] = data[src_index];
-      transposed_data[dst_index + 1] = data[src_index + 1];
-      transposed_data[dst_index + 2] = data[src_index + 2];
-      transposed_data[dst_index + 3] = data[src_index + 3];
-    }
-  }
-
-  return transposed_data;
-}
-// File: src/foerstner_corner.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "conversion.h"
-#include "perspectivetransform.h"
-#else
-#include "flatcv.h"
-#endif
-
-/** Implementation of the Foerstner corner measure response image.
- * Expected input is a grayscale image.
- */
-uint8_t *fcv_foerstner_corner(
-  uint32_t width,
-  uint32_t height,
-  uint8_t const *const gray_data,
-  double sigma
-) {
-  if (!gray_data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Allocate memory for gradient images
-  double *grad_x = calloc(width * height, sizeof(double));
-  double *grad_y = calloc(width * height, sizeof(double));
-
-  if (!grad_x || !grad_y) {
-    free(grad_x);
-    free(grad_y);
-    return NULL;
-  }
-
-  // Compute image gradients using Sobel-like operators
-  for (uint32_t y = 1; y < height - 1; y++) {
-    for (uint32_t x = 1; x < width - 1; x++) {
-      uint32_t idx = y * width + x;
-
-      // Sobel X kernel: [-1 0 1; -2 0 2; -1 0 1]
-      double gx = 0.0;
-      gx += -1.0 * gray_data[(y - 1) * width + (x - 1)];
-      gx += 1.0 * gray_data[(y - 1) * width + (x + 1)];
-      gx += -2.0 * gray_data[y * width + (x - 1)];
-      gx += 2.0 * gray_data[y * width + (x + 1)];
-      gx += -1.0 * gray_data[(y + 1) * width + (x - 1)];
-      gx += 1.0 * gray_data[(y + 1) * width + (x + 1)];
-
-      // Sobel Y kernel: [-1 -2 -1; 0 0 0; 1 2 1]
-      double gy = 0.0;
-      gy += -1.0 * gray_data[(y - 1) * width + (x - 1)];
-      gy += -2.0 * gray_data[(y - 1) * width + x];
-      gy += -1.0 * gray_data[(y - 1) * width + (x + 1)];
-      gy += 1.0 * gray_data[(y + 1) * width + (x - 1)];
-      gy += 2.0 * gray_data[(y + 1) * width + x];
-      gy += 1.0 * gray_data[(y + 1) * width + (x + 1)];
-
-      grad_x[idx] = gx / 8.0; // Normalize
-      grad_y[idx] = gy / 8.0;
-    }
-  }
-
-  // Compute gradient products for structure tensor
-  double *Axx = malloc(width * height * sizeof(double));
-  double *Axy = malloc(width * height * sizeof(double));
-  double *Ayy = malloc(width * height * sizeof(double));
-
-  if (!Axx || !Axy || !Ayy) {
-    free(grad_x);
-    free(grad_y);
-    free(Axx);
-    free(Axy);
-    free(Ayy);
-    return NULL;
-  }
-
-  for (uint32_t i = 0; i < width * height; i++) {
-    Axx[i] = grad_x[i] * grad_x[i];
-    Axy[i] = grad_x[i] * grad_y[i];
-    Ayy[i] = grad_y[i] * grad_y[i];
-  }
-
-  // Apply Gaussian smoothing to structure tensor elements
-  // For simplicity, use a basic box filter approximation when sigma is small
-  int32_t kernel_size = (int32_t)(3 * sigma) | 1; // Ensure odd size
-  if (kernel_size < 3) {
-    kernel_size = 3;
-  }
-  int32_t half_kernel = kernel_size / 2;
-
-  double *Axx_smooth = calloc(width * height, sizeof(double));
-  double *Axy_smooth = calloc(width * height, sizeof(double));
-  double *Ayy_smooth = calloc(width * height, sizeof(double));
-
-  if (!Axx_smooth || !Axy_smooth || !Ayy_smooth) {
-    free(grad_x);
-    free(grad_y);
-    free(Axx);
-    free(Axy);
-    free(Ayy);
-    free(Axx_smooth);
-    free(Axy_smooth);
-    free(Ayy_smooth);
-    return NULL;
-  }
-
-  // Simple box filter smoothing
-  for (uint32_t y = half_kernel; y < height - half_kernel; y++) {
-    for (uint32_t x = half_kernel; x < width - half_kernel; x++) {
-      uint32_t idx = y * width + x;
-      double sum_xx = 0.0, sum_xy = 0.0, sum_yy = 0.0;
-      int32_t count = 0;
-
-      for (int32_t ky = -half_kernel; ky <= half_kernel; ky++) {
-        for (int32_t kx = -half_kernel; kx <= half_kernel; kx++) {
-          int32_t sample_idx = (y + ky) * width + (x + kx);
-          sum_xx += Axx[sample_idx];
-          sum_xy += Axy[sample_idx];
-          sum_yy += Ayy[sample_idx];
-          count++;
-        }
-      }
-
-      Axx_smooth[idx] = sum_xx / count;
-      Axy_smooth[idx] = sum_xy / count;
-      Ayy_smooth[idx] = sum_yy / count;
-    }
-  }
-
-  // Compute Foerstner measures w and q
-  uint8_t *result = malloc(width * height * 2); // 2 channels: w, q
-  if (!result) {
-    free(grad_x);
-    free(grad_y);
-    free(Axx);
-    free(Axy);
-    free(Ayy);
-    free(Axx_smooth);
-    free(Axy_smooth);
-    free(Ayy_smooth);
-    return NULL;
-  }
-
-  double max_w = 0.0, max_q = 0.0;
-  double *w_values = malloc(width * height * sizeof(double));
-  double *q_values = malloc(width * height * sizeof(double));
-  if (!w_values || !q_values) {
-    free(result);
-    free(w_values);
-    free(q_values);
-    free(grad_x);
-    free(grad_y);
-    free(Axx);
-    free(Axy);
-    free(Ayy);
-    free(Axx_smooth);
-    free(Axy_smooth);
-    free(Ayy_smooth);
-    return NULL;
-  }
-
-  // First pass: compute w and q values and find maximum for normalization
-  for (uint32_t i = 0; i < width * height; i++) {
-    double det_A =
-      Axx_smooth[i] * Ayy_smooth[i] - Axy_smooth[i] * Axy_smooth[i];
-    double trace_A = Axx_smooth[i] + Ayy_smooth[i];
-
-    double w = 0.0, q = 0.0;
-
-    if (fabs(trace_A) > 1e-10) { // Avoid division by zero
-      w = det_A / trace_A;
-      q = 4.0 * det_A / (trace_A * trace_A);
-    }
-
-    w_values[i] = fmax(0.0, w); // Ensure non-negative
-    q_values[i] = fmax(0.0, q);
-
-    if (w_values[i] > max_w) {
-      max_w = w_values[i];
-    }
-    if (q_values[i] > max_q) {
-      max_q = q_values[i];
-    }
-  }
-
-  // Second pass: normalize and convert to bytes
-  for (uint32_t i = 0; i < width * height; i++) {
-    uint8_t w_byte = 0, q_byte = 0;
-
-    if (max_w > 0.0) {
-      w_byte = (uint8_t)(255.0 * w_values[i] / max_w);
-    }
-    if (max_q > 0.0) {
-      q_byte = (uint8_t)(255.0 * q_values[i] / max_q);
-    }
-
-    result[i * 2] = w_byte;     // w measure
-    result[i * 2 + 1] = q_byte; // q measure
-  }
-
-  free(w_values);
-  free(q_values);
-
-  // Cleanup
-  free(grad_x);
-  free(grad_y);
-  free(Axx);
-  free(Axy);
-  free(Ayy);
-  free(Axx_smooth);
-  free(Axy_smooth);
-  free(Ayy_smooth);
-
-  return result;
-}
-// File: src/histogram.c
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "histogram.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Generate a histogram visualization image from input image data.
- * For grayscale images, creates a single histogram.
- * For RGB(A) images, creates overlapping histograms for each channel.
- *
- * @param width Width of the input image.
- * @param height Height of the input image.
- * @param channels Number of channels in the input image (1, 3, or 4).
- * @param data Pointer to the input pixel data.
- * @param out_width Pointer to store the output histogram width.
- * @param out_height Pointer to store the output histogram height.
- * @return Pointer to the histogram image data (RGBA format).
- */
-uint8_t *fcv_generate_histogram(
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  uint8_t const *const data,
-  uint32_t *out_width,
-  uint32_t *out_height
-) {
-  if (!data || !out_width || !out_height) {
-    return NULL;
-  }
-
-  if (width == 0 || height == 0 || channels == 0) {
-    return NULL;
-  }
-
-  // Check for overflow in img_size calculation
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t img_size = (size_t)width * height;
-
-  // Validate that we can access all pixels
-  if (img_size > SIZE_MAX / channels) {
-    return NULL;
-  }
-
-  // Output histogram dimensions
-  const uint32_t hist_width = 256;  // One pixel per brightness value
-  const uint32_t hist_height = 200; // Fixed height for visualization
-
-  *out_width = hist_width;
-  *out_height = hist_height;
-
-  // Output size is fixed: 256 * 200 * 4 = 204800 bytes (no overflow possible)
-  size_t output_size = (size_t)hist_width * hist_height * 4;
-
-  // Allocate output image (initialized to black background)
-  uint8_t *output = calloc(output_size, sizeof(uint8_t));
-  if (!output) {
-    return NULL;
-  }
-
-  // Initialize with black background and full alpha
-  for (uint32_t i = 0; i < (*out_width) * (*out_height); i++) {
-    output[i * 4 + 3] = 255; // Alpha channel
-  }
-
-  // Count histograms for each channel
-  uint32_t hist_r[256] = {0};
-  uint32_t hist_g[256] = {0};
-  uint32_t hist_b[256] = {0};
-  uint32_t hist_gray[256] = {0};
-
-  bool is_grayscale = (channels == 1);
-  if (channels == 4) {
-    // Check if it's actually grayscale (all RGB channels equal)
-    is_grayscale = true;
-    for (size_t i = 0; i < img_size && is_grayscale; i++) {
-      size_t idx = i * 4;
-      if (data[idx] != data[idx + 1] || data[idx] != data[idx + 2]) {
-        is_grayscale = false;
-      }
-    }
-  }
-
-  if (is_grayscale) {
-    // Single channel or grayscale image
-    for (size_t i = 0; i < img_size; i++) {
-      uint8_t value;
-      if (channels == 1) {
-        value = data[i];
-      }
-      else {
-        // Use the red channel for grayscale RGBA
-        value = data[i * 4];
-      }
-      hist_gray[value]++;
-    }
-  }
-  else {
-    // RGB or RGBA image - count each channel
-    for (size_t i = 0; i < img_size; i++) {
-      size_t idx = i * channels;
-      hist_r[data[idx]]++;
-      if (channels >= 3) {
-        hist_g[data[idx + 1]]++;
-        hist_b[data[idx + 2]]++;
-      }
-    }
-  }
-
-  // Find maximum count for scaling
-  uint32_t max_count = 0;
-  if (is_grayscale) {
-    for (int i = 0; i < 256; i++) {
-      if (hist_gray[i] > max_count) {
-        max_count = hist_gray[i];
-      }
-    }
-  }
-  else {
-    for (int i = 0; i < 256; i++) {
-      if (hist_r[i] > max_count) {
-        max_count = hist_r[i];
-      }
-      if (channels >= 3) {
-        if (hist_g[i] > max_count) {
-          max_count = hist_g[i];
-        }
-        if (hist_b[i] > max_count) {
-          max_count = hist_b[i];
-        }
-      }
-    }
-  }
-
-  if (max_count == 0) {
-    return output; // Return black image if no data
-  }
-
-  // Draw histogram bars
-  for (int x = 0; x < 256; x++) {
-    if (is_grayscale) {
-      // Draw white histogram for grayscale
-      uint32_t bar_height = (hist_gray[x] * hist_height) / max_count;
-      for (uint32_t y = 0; y < bar_height; y++) {
-        uint32_t output_x = x;
-        uint32_t output_y = hist_height - 1 - y;
-        uint32_t output_idx = (output_y * (*out_width) + output_x) * 4;
-
-        output[output_idx] = 255;     // R
-        output[output_idx + 1] = 255; // G
-        output[output_idx + 2] = 255; // B
-        output[output_idx + 3] = 255; // A
-      }
-    }
-    else {
-      // Draw overlapping colored histograms for RGB
-      uint32_t bar_height_r = (hist_r[x] * hist_height) / max_count;
-      uint32_t bar_height_g =
-        (channels >= 3) ? (hist_g[x] * hist_height) / max_count : 0;
-      uint32_t bar_height_b =
-        (channels >= 3) ? (hist_b[x] * hist_height) / max_count : 0;
-
-      uint32_t max_bar_height = bar_height_r;
-      if (bar_height_g > max_bar_height) {
-        max_bar_height = bar_height_g;
-      }
-      if (bar_height_b > max_bar_height) {
-        max_bar_height = bar_height_b;
-      }
-
-      for (uint32_t y = 0; y < max_bar_height; y++) {
-        uint32_t output_x = x;
-        uint32_t output_y = hist_height - 1 - y;
-        uint32_t output_idx = (output_y * (*out_width) + output_x) * 4;
-
-        // Initialize to black
-        uint8_t r = 0, g = 0, b = 0;
-
-        // Add red channel contribution
-        if (y < bar_height_r) {
-          r = 255;
-        }
-
-        // Add green channel contribution
-        if (channels >= 3 && y < bar_height_g) {
-          g = 255;
-        }
-
-        // Add blue channel contribution
-        if (channels >= 3 && y < bar_height_b) {
-          b = 255;
-        }
-
-        output[output_idx] = r;       // R
-        output[output_idx + 1] = g;   // G
-        output[output_idx + 2] = b;   // B
-        output[output_idx + 3] = 255; // A
-      }
-    }
-  }
-
-  return output;
-}
-// File: src/parse_hex_color.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "parse_hex_color.h"
-#else
-#include "flatcv.h"
-#endif
-
-// Helper function to parse hex color code to RGB values
-void fcv_parse_hex_color(
-  const char *hex_color,
-  uint8_t *r,
-  uint8_t *g,
-  uint8_t *b
-) {
-  const char *hex = hex_color;
-
-  // Skip '#' if present
-  if (hex[0] == '#') {
-    hex++;
-  }
-
-  // Validate hex string length (should be 6 characters)
-  if (strlen(hex) != 6) {
-    // Default to white for invalid hex codes
-    *r = 255;
-    *g = 255;
-    *b = 255;
-    return;
-  }
-
-  // Validate that all characters are valid hex digits
-  for (int32_t i = 0; i < 6; i++) {
-    if (!((hex[i] >= '0' && hex[i] <= '9') ||
-          (hex[i] >= 'A' && hex[i] <= 'F') ||
-          (hex[i] >= 'a' && hex[i] <= 'f'))) {
-      // Default to white for invalid hex codes
-      *r = 255;
-      *g = 255;
-      *b = 255;
-      return;
-    }
-  }
-
-  // Parse hex values
-  uint32_t rgb_value;
-  sscanf(hex, "%x", &rgb_value);
-
-  *r = (rgb_value >> 16) & 0xFF;
-  *g = (rgb_value >> 8) & 0xFF;
-  *b = rgb_value & 0xFF;
-}
-// File: src/perspectivetransform.c
-#include <assert.h>
-#include <math.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "perspectivetransform.h"
-#else
-#include "flatcv.h"
-#endif
-
-// #define DEBUG_LOGGING
-
-#ifdef DEBUG_LOGGING
-#define log(msg) printf("DEBUG: %s\n", msg)
-#else
-#define log(msg) // No operation
-#endif
-
-/**
- * Helper function to solve 8x8 linear system using Gaussian elimination
- * Returns 1 on success, 0 on failure
- */
-int32_t solve_linear_system(double A[8][8], double b[8], double x[8]) {
-  const int32_t n = 8;
-  const double epsilon = 1e-10;
-  int32_t i, j, k;
-
-  // Create augmented matrix [A|b] with extra safety margin
-  double aug[8][10]; // One extra column for safety
-
-  // Initialize augmented matrix
-  for (i = 0; i < n; i++) {
-    for (j = 0; j < n; j++) {
-      aug[i][j] = A[i][j];
-    }
-    aug[i][n] = b[i];
-  }
-
-  // Gaussian elimination with partial pivoting
-  for (i = 0; i < n; i++) {
-    // Find pivot
-    int32_t max_row = i;
-    double max_val = fabs(aug[i][i]);
-
-    for (k = i + 1; k < n; k++) {
-      if (fabs(aug[k][i]) > max_val) {
-        max_val = fabs(aug[k][i]);
-        max_row = k;
-      }
-    }
-
-    // Check for singularity
-    if (max_val < epsilon) {
-      log("Warning: Matrix is nearly singular\n");
-      return 0;
-    }
-
-    // Swap maximum row with current row
-    if (max_row != i) {
-      for (j = 0; j <= n; j++) {
-        double temp = aug[i][j];
-        aug[i][j] = aug[max_row][j];
-        aug[max_row][j] = temp;
-      }
-    }
-
-    // Eliminate column i
-    for (j = i + 1; j < n; j++) {
-      double factor = aug[j][i] / aug[i][i];
-      for (k = i; k <= n; k++) {
-        aug[j][k] -= factor * aug[i][k];
-      }
-    }
-  }
-
-  // Back substitution
-  for (i = n - 1; i >= 0; i--) {
-    if (fabs(aug[i][i]) < epsilon) {
-      log("Warning: Zero pivot encountered\n");
-      return 0;
-    }
-
-    x[i] = aug[i][n];
-    for (j = i + 1; j < n; j++) {
-      x[i] -= aug[i][j] * x[j];
-    }
-    x[i] /= aug[i][i];
-
-    // Check for invalid results
-    if (isnan(x[i]) || isinf(x[i])) {
-      log("Warning: Invalid result detected\n");
-      return 0;
-    }
-  }
-
-  return 1;
-}
-
-/**
- * Calculate the perspective transformation matrix
- * from the source and destination corner coordinates.
- */
-Matrix3x3 *fcv_calculate_perspective_transform(
-  Corners *src_corners,
-  Corners *dst_corners
-) {
-  // Initialize matrices with zeros
-  double A[8][8] = {{0}};
-  double b[8] = {0};
-  double x[8] = {0};
-
-  // Identity matrix as fallback
-  static Matrix3x3 identity = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0};
-
-  if (!src_corners || !dst_corners) {
-    log("Error: NULL pointer passed to calculate_perspective_transform\n");
-    return &identity;
-  }
-
-#ifdef DEBUG_LOGGING
-  fprintf(stderr, "[C] Calculating perspective transform:\n");
-  fprintf(
-    stderr,
-    "src_corners:\ntl(%f, %f)\ntr(%f, %f)\nbr(%f, %f)\nbl(%f, %f)\n\n",
-    src_corners->tl_x,
-    src_corners->tl_y,
-    src_corners->tr_x,
-    src_corners->tr_y,
-    src_corners->br_x,
-    src_corners->br_y,
-    src_corners->bl_x,
-    src_corners->bl_y
-  );
-  fprintf(
-    stderr,
-    "dst_corners:\ntl(%f, %f)\ntr(%f, %f)\nbr(%f, %f)\nbl(%f, %f)\n\n",
-    dst_corners->tl_x,
-    dst_corners->tl_y,
-    dst_corners->tr_x,
-    dst_corners->tr_y,
-    dst_corners->br_x,
-    dst_corners->br_y,
-    dst_corners->bl_x,
-    dst_corners->bl_y
-  );
-#endif
-
-  // Validate input coordinates
-  if (isnan(src_corners->tl_x) || isnan(src_corners->tl_y) ||
-      isnan(src_corners->tr_x) || isnan(src_corners->tr_y) ||
-      isnan(src_corners->br_x) || isnan(src_corners->br_y) ||
-      isnan(src_corners->bl_x) || isnan(src_corners->bl_y) ||
-      isnan(dst_corners->tl_x) || isnan(dst_corners->tl_y) ||
-      isnan(dst_corners->tr_x) || isnan(dst_corners->tr_y) ||
-      isnan(dst_corners->br_x) || isnan(dst_corners->br_y) ||
-      isnan(dst_corners->bl_x) || isnan(dst_corners->bl_y)) {
-    log("Error: Invalid coordinates (NaN) detected\n");
-    return &identity;
-  }
-
-  // Set up the system of equations
-  for (int32_t i = 0; i < 4; i++) {
-    double srcX = 0.0, srcY = 0.0, dstX = 0.0, dstY = 0.0;
-
-    // Safely extract coordinates
-    switch (i) {
-    case 0: // Top-left
-      srcX = src_corners->tl_x;
-      srcY = src_corners->tl_y;
-      dstX = dst_corners->tl_x;
-      dstY = dst_corners->tl_y;
-      break;
-    case 1: // Top-right
-      srcX = src_corners->tr_x;
-      srcY = src_corners->tr_y;
-      dstX = dst_corners->tr_x;
-      dstY = dst_corners->tr_y;
-      break;
-    case 2: // Bottom-right
-      srcX = src_corners->br_x;
-      srcY = src_corners->br_y;
-      dstX = dst_corners->br_x;
-      dstY = dst_corners->br_y;
-      break;
-    case 3: // Bottom-left
-      srcX = src_corners->bl_x;
-      srcY = src_corners->bl_y;
-      dstX = dst_corners->bl_x;
-      dstY = dst_corners->bl_y;
-      break;
-    }
-
-    // Validate extracted coordinates
-    if (isinf(srcX) || isinf(srcY) || isinf(dstX) || isinf(dstY)) {
-      log("Error: Invalid coordinates (Inf) detected\n");
-      return &identity;
-    }
-
-    // First four equations for x coordinates
-    A[i][0] = srcX;
-    A[i][1] = srcY;
-    A[i][2] = 1.0;
-    A[i][6] = -srcX * dstX;
-    A[i][7] = -srcY * dstX;
-    b[i] = dstX;
-
-    // Last four equations for y coordinates
-    A[i + 4][3] = srcX;
-    A[i + 4][4] = srcY;
-    A[i + 4][5] = 1.0;
-    A[i + 4][6] = -srcX * dstY;
-    A[i + 4][7] = -srcY * dstY;
-    b[i + 4] = dstY;
-  }
-
-  log("Solve the system of equations …\n");
-  if (!solve_linear_system(A, b, x)) {
-    log("Failed to solve system, returning identity matrix\n");
-    return &identity;
-  }
-
-  // Validate solution
-  for (int32_t i = 0; i < 8; i++) {
-    if (isnan(x[i]) || isinf(x[i]) || fabs(x[i]) > 1e6) {
-      log("Error: Invalid solution values detected\n");
-      return &identity;
-    }
-  }
-
-  Matrix3x3 *result = malloc(sizeof(Matrix3x3));
-  *result = (Matrix3x3){x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1.0};
-
-#ifdef DEBUG_LOGGING
-  fprintf(stderr, "Result matrix:\n");
-  fprintf(stderr, "%f, %f, %f\n", result->m00, result->m01, result->m02);
-  fprintf(stderr, "%f, %f, %f\n", result->m10, result->m11, result->m12);
-  fprintf(stderr, "%f, %f, %f\n", result->m20, result->m21, result->m22);
-#endif
-
-  // Final validation of the result matrix
-  if (isnan(result->m00) || isnan(result->m01) || isnan(result->m02) ||
-      isnan(result->m10) || isnan(result->m11) || isnan(result->m12) ||
-      isnan(result->m20) || isnan(result->m21) || isnan(result->m22) ||
-      isinf(result->m00) || isinf(result->m01) || isinf(result->m02) ||
-      isinf(result->m10) || isinf(result->m11) || isinf(result->m12) ||
-      isinf(result->m20) || isinf(result->m21) || isinf(result->m22)) {
-    log("Error: Invalid values in result matrix\n");
-    return &identity;
-  }
-
-  return result;
-}
-
-/**
- * Apply the transformation matrix to the input image
- * and store the result in the output image.
- * Use bilinear interpolation to calculate final pixel values.
- */
-uint8_t *fcv_apply_matrix_3x3(
-  int32_t in_width,
-  int32_t in_height,
-  uint8_t *in_data,
-  int32_t out_width,
-  int32_t out_height,
-  Matrix3x3 *tmat
-) {
-  if (!in_data || !tmat) {
-    return NULL;
-  }
-
-  if (in_width <= 0 || in_height <= 0 || out_width <= 0 || out_height <= 0) {
-    return NULL;
-  }
-
-  // Check for overflow: out_width * out_height * 4 (dimensions already
-  // validated > 0)
-  if ((size_t)out_width > SIZE_MAX / (size_t)out_height) {
-    return NULL;
-  }
-  size_t out_pixels = (size_t)out_width * (size_t)out_height;
-  if (out_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-
-  // Patch flip matrix if needed
-  if (fabs(tmat->m00 + 1.0) < 1e-9 && fabs(tmat->m11 + 1.0) < 1e-9 &&
-      tmat->m02 == 0.0 && tmat->m12 == 0.0) {
-    tmat->m02 = in_width - 1;
-    tmat->m12 = in_height - 1;
-  }
-
-  uint8_t *out_data = calloc(out_pixels * 4, sizeof(uint8_t));
-
-  if (!out_data) { // Memory allocation failed
-    return NULL;
-  }
-
-  // Iterate through every pixel in the output image
-  for (int32_t out_y = 0; out_y < out_height; ++out_y) {
-    for (int32_t out_x = 0; out_x < out_width; ++out_x) {
-      // Apply the inverse transformation to find the corresponding source pixel
-      double w = tmat->m20 * out_x + tmat->m21 * out_y + tmat->m22;
-      if (fabs(w) < 1e-10) {
-        continue; // Skip if w is too close to zero
-      }
-
-      double srcX = (tmat->m00 * out_x + tmat->m01 * out_y + tmat->m02) / w;
-      double srcY = (tmat->m10 * out_x + tmat->m11 * out_y + tmat->m12) / w;
-
-      // Convert source coordinates to integers
-      int32_t x0 = (int32_t)floor(srcX);
-      int32_t y0 = (int32_t)floor(srcY);
-      int32_t x1 = x0 + 1;
-      int32_t y1 = y0 + 1;
-
-      // Verify that the anchor pixel is inside the source image
-      if (x0 >= 0 && x0 < in_width && y0 >= 0 && y0 < in_height) {
-
-        // Clamp the neighbor coordinates so that a (degenerated)
-        // bilinear interpolation can be applied at the image borders.
-        int32_t x1c = (x1 < in_width) ? x1 : x0;
-        int32_t y1c = (y1 < in_height) ? y1 : y0;
-
-        double dx = srcX - x0;
-        double dy = srcY - y0;
-
-        // If a neighbour got clamped we force the corresponding weight to 0
-        if (x1c == x0) {
-          dx = 0.0;
-        }
-        if (y1c == y0) {
-          dy = 0.0;
-        }
-
-        uint8_t *p00 = &in_data[(y0 * in_width + x0) * 4];
-        uint8_t *p01 = &in_data[(y0 * in_width + x1c) * 4];
-        uint8_t *p10 = &in_data[(y1c * in_width + x0) * 4];
-        uint8_t *p11 = &in_data[(y1c * in_width + x1c) * 4];
-
-        for (int32_t c = 0; c < 4; ++c) {
-          out_data[(out_y * out_width + out_x) * 4 + c] =
-            (uint8_t)(p00[c] * (1 - dx) * (1 - dy) + p01[c] * dx * (1 - dy) +
-                      p10[c] * (1 - dx) * dy + p11[c] * dx * dy);
-        }
-      }
-    }
-  }
-
-  return out_data;
-}
-// File: src/rgba_to_grayscale.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "rgba_to_grayscale.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Convert raw RGBA row-major top-to-bottom image data
- * to a single channel grayscale image data.
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param data Pointer to the pixel data.
- * @return Pointer to the single channel grayscale image data.
- */
-uint8_t *fcv_rgba_to_grayscale(
-  uint32_t width,
-  uint32_t height,
-  uint8_t const *const data
-) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t img_length_px = (size_t)width * height;
-
-  uint8_t *grayscale_data = malloc(img_length_px);
-
-  if (!grayscale_data) { // Memory allocation failed
-    return NULL;
-  }
-
-  // Process each pixel row by row
-  for (size_t i = 0; i < img_length_px; i++) {
-    size_t rgba_index = i * 4;
-
-    uint8_t r = data[rgba_index];
-    uint8_t g = data[rgba_index + 1];
-    uint8_t b = data[rgba_index + 2];
-
-    uint8_t gray = (r * R_WEIGHT + g * G_WEIGHT + b * B_WEIGHT) >> 8;
-
-    grayscale_data[i] = gray;
-  }
-
-  return grayscale_data;
-}
-// File: src/rotate.c
-#include <stdint.h>
-#include <stdlib.h>
-#include <string.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "rotate.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Rotate an image 90 degrees clockwise.
- */
-uint8_t *
-fcv_rotate_90_cw(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *rotated_data = malloc(img_length_byte);
-
-  if (!rotated_data) {
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index = ((size_t)x * height + (height - 1 - y)) * 4;
-
-      rotated_data[dst_index] = data[src_index];
-      rotated_data[dst_index + 1] = data[src_index + 1];
-      rotated_data[dst_index + 2] = data[src_index + 2];
-      rotated_data[dst_index + 3] = data[src_index + 3];
-    }
-  }
-
-  return rotated_data;
-}
-
-/**
- * Rotate an image 180 degrees.
- */
-uint8_t *
-fcv_rotate_180(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *rotated_data = malloc(img_length_byte);
-
-  if (!rotated_data) {
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index =
-        ((size_t)(height - 1 - y) * width + (width - 1 - x)) * 4;
-
-      rotated_data[dst_index] = data[src_index];
-      rotated_data[dst_index + 1] = data[src_index + 1];
-      rotated_data[dst_index + 2] = data[src_index + 2];
-      rotated_data[dst_index + 3] = data[src_index + 3];
-    }
-  }
-
-  return rotated_data;
-}
-
-/**
- * Rotate an image 270 degrees clockwise (90 degrees counter-clockwise).
- */
-uint8_t *
-fcv_rotate_270_cw(uint32_t width, uint32_t height, uint8_t const *const data) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t num_pixels = (size_t)width * height;
-  if (num_pixels > SIZE_MAX / 4) {
-    return NULL;
-  }
-  size_t img_length_byte = num_pixels * 4;
-
-  uint8_t *rotated_data = malloc(img_length_byte);
-
-  if (!rotated_data) {
-    return NULL;
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      size_t src_index = ((size_t)y * width + x) * 4;
-      size_t dst_index = ((size_t)(width - 1 - x) * height + y) * 4;
-
-      rotated_data[dst_index] = data[src_index];
-      rotated_data[dst_index + 1] = data[src_index + 1];
-      rotated_data[dst_index + 2] = data[src_index + 2];
-      rotated_data[dst_index + 3] = data[src_index + 3];
-    }
-  }
-
-  return rotated_data;
-}
-// File: src/single_to_multichannel.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "single_to_multichannel.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Convert single channel grayscale image data to
- * RGBA row-major top-to-bottom image data.
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param data Pointer to the pixel data.
- */
-uint8_t *fcv_single_to_multichannel(
-  uint32_t width,
-  uint32_t height,
-  uint8_t const *const data
-) {
-  if (!data || width == 0 || height == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height * 4
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t img_length_px = (size_t)width * height;
-  if (img_length_px > SIZE_MAX / 4) {
-    return NULL;
-  }
-
-  uint8_t *multichannel_data = malloc(img_length_px * 4);
-
-  if (!multichannel_data) { // Memory allocation failed
-    return NULL;
-  }
-
-  for (size_t i = 0; i < img_length_px; i++) {
-    size_t rgba_index = i * 4;
-    multichannel_data[rgba_index] = data[i];
-    multichannel_data[rgba_index + 1] = data[i];
-    multichannel_data[rgba_index + 2] = data[i];
-    multichannel_data[rgba_index + 3] = 255;
-  }
-
-  return multichannel_data;
-}
-// File: src/sobel_edge_detection.c
-#include <assert.h>
-#include <math.h>
-#include <stdbool.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <time.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "conversion.h"
-#include "perspectivetransform.h"
-#include "rgba_to_grayscale.h"
-#include "sobel_edge_detection.h"
-#else
-#include "flatcv.h"
-#endif
-
-/**
- * Apply Sobel edge detection to the image data and return single-channel
- * grayscale data. Uses Sobel kernels to detect edges in horizontal and vertical
- * directions, then combines them to get the edge magnitude.
- *
- * @param width Width of the image.
- * @param height Height of the image.
- * @param channels Number of channels in the input image.
- *                 (1: Grayscale, 3: RGB, 4: RGBA)
- * @param data Pointer to the input pixel data.
- * @return Pointer to the single-channel grayscale edge-detected image data.
- */
-uint8_t *fcv_sobel_edge_detection(
-  uint32_t width,
-  uint32_t height,
-  uint32_t channels,
-  uint8_t const *const data
-) {
-  if (!data || width == 0 || height == 0 || channels == 0) {
-    return NULL;
-  }
-
-  // Check for overflow: width * height
-  if (width > SIZE_MAX / height) {
-    return NULL;
-  }
-  size_t img_length_px = (size_t)width * height;
-
-  // Check for overflow in magnitudes allocation
-  if (img_length_px > SIZE_MAX / sizeof(double)) {
-    return NULL;
-  }
-
-  uint8_t *grayscale_data;
-  bool allocated_grayscale = false;
-
-  if (channels == 1) {
-    // Single-channel input, use data directly
-    grayscale_data = (uint8_t *)data;
-    allocated_grayscale = false;
-  }
-  else {
-    // Multi-channel input, convert to grayscale
-    grayscale_data = fcv_rgba_to_grayscale(width, height, data);
-    if (!grayscale_data) {
-      return NULL;
-    }
-    allocated_grayscale = true;
-  }
-
-  uint8_t *sobel_data = malloc(img_length_px);
-  if (!sobel_data) {
-    if (allocated_grayscale) {
-      free(grayscale_data);
-    }
-    return NULL;
-  }
-
-  // Sobel kernels
-  int32_t sobel_x[3][3] = {{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}};
-  int32_t sobel_y[3][3] = {{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}};
-
-  // Temporary buffer to store magnitudes for normalization
-  double *magnitudes = malloc(img_length_px * sizeof(double));
-  if (!magnitudes) {
-    if (allocated_grayscale) {
-      free(grayscale_data);
-    }
-    free(sobel_data);
-    return NULL;
-  }
-
-  double min_magnitude = INFINITY;
-  double max_magnitude = 0.0;
-
-  // First pass: calculate all magnitudes and find min/max
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      int32_t gx = 0;
-      int32_t gy = 0;
-
-      // Apply Sobel kernels
-      for (int32_t ky = -1; ky <= 1; ky++) {
-        for (int32_t kx = -1; kx <= 1; kx++) {
-          int32_t px = x + kx;
-          int32_t py = y + ky;
-
-          // Handle boundaries by using nearest pixel values
-          if (px < 0) {
-            px = 0;
-          }
-          if ((uint32_t)px >= width) {
-            px = width - 1;
-          }
-          if (py < 0) {
-            py = 0;
-          }
-          if ((uint32_t)py >= height) {
-            py = height - 1;
-          }
-
-          uint8_t pixel = grayscale_data[(size_t)py * width + px];
-          gx += pixel * sobel_x[ky + 1][kx + 1];
-          gy += pixel * sobel_y[ky + 1][kx + 1];
-        }
-      }
-
-      double magnitude = sqrt((double)gx * gx + (double)gy * gy);
-      magnitudes[(size_t)y * width + x] = magnitude;
-
-      if (magnitude < min_magnitude) {
-        min_magnitude = magnitude;
-      }
-      if (magnitude > max_magnitude) {
-        max_magnitude = magnitude;
-      }
-    }
-  }
-
-  // Second pass: normalize and assign to output
-  double range = max_magnitude - min_magnitude;
-  if (range == 0.0) {
-    range = 1.0; // Avoid division by zero
-  }
-
-  for (uint32_t y = 0; y < height; y++) {
-    for (uint32_t x = 0; x < width; x++) {
-      double magnitude = magnitudes[(size_t)y * width + x];
-
-      // Normalize to 0-255 range based on actual min/max
-      int32_t final_magnitude =
-        (int32_t)(((magnitude - min_magnitude) / range) * 255.0);
-
-      // Clamp to valid range
-      if (final_magnitude < 0) {
-        final_magnitude = 0;
-      }
-      if (final_magnitude > 255) {
-        final_magnitude = 255;
-      }
-
-      sobel_data[(size_t)y * width + x] = (uint8_t)final_magnitude;
-    }
-  }
-
-  free(magnitudes);
-
-  if (allocated_grayscale) {
-    free(grayscale_data);
-  }
-  return sobel_data;
-}
-// File: src/sort_corners.c
-#include <math.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-
-#ifndef FLATCV_AMALGAMATION
-#include "1_types.h"
-#else
-#include "flatcv.h"
-#endif
-
-typedef struct {
-  double r;     // radius
-  double theta; // angle
-} PolarPoint;
-
-// Helper function to convert Cartesian to polar coordinates
-PolarPoint cartesian_to_polar(Point2D cart) {
-  PolarPoint polar;
-  polar.r = sqrt(cart.x * cart.x + cart.y * cart.y);
-  double theta = atan2(cart.y, cart.x);
-
-  // Normalize theta: Convert to degrees and apply normalization
-  double theta_deg = theta * 180.0 / M_PI;
-  if (theta_deg < 0) {
-    polar.theta = -theta_deg;
-  }
-  else {
-    polar.theta = -(theta_deg - 360.0);
-  }
-
-  return polar;
-}
-
-// Comparison function for qsort (sorting by angle in descending order)
-int compare_polar_angles(const void *a, const void *b) {
-  const PolarPoint *pa = (const PolarPoint *)a;
-  const PolarPoint *pb = (const PolarPoint *)b;
-
-  if (pa->theta < pb->theta) {
-    return 1; // Descending order
-  }
-  if (pa->theta > pb->theta) {
-    return -1;
-  }
-  return 0;
-}
-
-// Helper structure to keep track of original indices during sorting
-typedef struct {
-  PolarPoint polar;
-  uint32_t original_index;
-} IndexedPolarPoint;
-
-int compare_indexed_polar_angles(const void *a, const void *b) {
-  const IndexedPolarPoint *pa = (const IndexedPolarPoint *)a;
-  const IndexedPolarPoint *pb = (const IndexedPolarPoint *)b;
-
-  if (pa->polar.theta < pb->polar.theta) {
-    return 1; // Descending order
-  }
-  if (pa->polar.theta > pb->polar.theta) {
-    return -1;
-  }
-  return 0;
-}
-
-Corners sort_corners(
-  uint32_t width,
-  uint32_t height,
-  uint32_t out_width,
-  uint32_t out_height,
-  Point2D *corners,
-  uint32_t num_corners,
-  Point2D *result
-) {
-  if (num_corners < 3) {
-    // Not enough corners, return zeros
-    Corners empty_corners = {0, 0, 0, 0, 0, 0, 0, 0};
-    return empty_corners;
-  }
-
-  // Allocate memory for working corners (max of num_corners or 4 for 3-corner
-  // case)
-  uint32_t max_corners = (num_corners >= 4) ? num_corners : 4;
-  Point2D *working_corners = malloc(max_corners * sizeof(Point2D));
-  if (!working_corners) {
-    Corners empty_corners = {0, 0, 0, 0, 0, 0, 0, 0};
-    return empty_corners;
-  }
-
-  uint32_t corners_to_process;
-
-  if (num_corners == 3) {
-    // Copy the 3 corners we have
-    working_corners[0] = corners[0];
-    working_corners[1] = corners[1];
-    working_corners[2] = corners[2];
-
-    // Calculate the fourth corner to complete a parallelogram
-    // For 3 corners A, B, C, we need to determine which corner is missing
-    // and calculate it using vector addition
-
-    // First, sort the 3 corners by position to understand the layout
-    Point2D sorted[3] = {corners[0], corners[1], corners[2]};
-
-    // Find bounding box of the 3 corners
-    double min_x = sorted[0].x, max_x = sorted[0].x;
-    double min_y = sorted[0].y, max_y = sorted[0].y;
-    for (int i = 1; i < 3; i++) {
-      if (sorted[i].x < min_x) {
-        min_x = sorted[i].x;
-      }
-      if (sorted[i].x > max_x) {
-        max_x = sorted[i].x;
-      }
-      if (sorted[i].y < min_y) {
-        min_y = sorted[i].y;
-      }
-      if (sorted[i].y > max_y) {
-        max_y = sorted[i].y;
-      }
-    }
-
-    // Determine which corner is missing by finding which position in the
-    // rectangle is empty
-    bool has_tl = false, has_tr = false, has_bl = false, has_br = false;
-    for (int i = 0; i < 3; i++) {
-      double x = sorted[i].x;
-      double y = sorted[i].y;
-
-      // Use small tolerance for floating point comparison
-      double tolerance = 1.0;
-
-      if (fabs(x - min_x) < tolerance && fabs(y - min_y) < tolerance) {
-        has_tl = true;
-      }
-      else if (fabs(x - max_x) < tolerance && fabs(y - min_y) < tolerance) {
-        has_tr = true;
-      }
-      else if (fabs(x - min_x) < tolerance && fabs(y - max_y) < tolerance) {
-        has_bl = true;
-      }
-      else if (fabs(x - max_x) < tolerance && fabs(y - max_y) < tolerance) {
-        has_br = true;
-      }
-    }
-
-    // Calculate the missing corner
-    Point2D missing_corner;
-    if (!has_tl) {
-      missing_corner.x = min_x;
-      missing_corner.y = min_y;
-    }
-    else if (!has_tr) {
-      missing_corner.x = max_x;
-      missing_corner.y = min_y;
-    }
-    else if (!has_bl) {
-      missing_corner.x = min_x;
-      missing_corner.y = max_y;
-    }
-    else if (!has_br) {
-      missing_corner.x = max_x;
-      missing_corner.y = max_y;
-    }
-    else {
-      // Fallback: use parallelogram completion
-      // For a parallelogram ABCD where we have A, B, C and need D:
-      // D = A + C - B (assuming B and D are diagonal)
-      // Try all combinations and pick the best one
-      Point2D candidates[3];
-      candidates[0].x = corners[0].x + corners[1].x - corners[2].x;
-      candidates[0].y = corners[0].y + corners[1].y - corners[2].y;
-      candidates[1].x = corners[0].x + corners[2].x - corners[1].x;
-      candidates[1].y = corners[0].y + corners[2].y - corners[1].y;
-      candidates[2].x = corners[1].x + corners[2].x - corners[0].x;
-      candidates[2].y = corners[1].y + corners[2].y - corners[0].y;
-
-      // Choose the candidate that gives the most reasonable result
-      missing_corner = candidates[2]; // Default to the last one
-      double best_score = -1;
-
-      for (int i = 0; i < 3; i++) {
-        double score = 0;
-        if (candidates[i].x >= 0) {
-          score += 1;
-        }
-        if (candidates[i].y >= 0) {
-          score += 1;
-        }
-
-        // Check if it's not a duplicate
-        bool is_duplicate = false;
-        for (int j = 0; j < 3; j++) {
-          if (fabs(candidates[i].x - corners[j].x) < 0.1 &&
-              fabs(candidates[i].y - corners[j].y) < 0.1) {
-            is_duplicate = true;
-            break;
-          }
-        }
-        if (!is_duplicate) {
-          score += 5;
-        }
-
-        if (score > best_score) {
-          best_score = score;
-          missing_corner = candidates[i];
-        }
-      }
-    }
-
-    working_corners[3] = missing_corner;
-    corners_to_process = 4;
-  }
-  else {
-    // Copy all existing corners
-    for (uint32_t i = 0; i < num_corners; i++) {
-      working_corners[i] = corners[i];
-    }
-    corners_to_process = num_corners;
-  }
-
-  // Clockwise corner sorting starting from top-left corner
-  // Works with any number of corners >= 3
-
-  // Step 1: Find the center point (centroid)
-  double center_x = 0.0, center_y = 0.0;
-  for (uint32_t i = 0; i < corners_to_process; i++) {
-    center_x += working_corners[i].x;
-    center_y += working_corners[i].y;
-  }
-  center_x /= corners_to_process;
-  center_y /= corners_to_process;
-
-  // Step 2: Find the top-left corner (minimum x+y sum)
-  uint32_t top_left_idx = 0;
-  double min_sum = working_corners[0].x + working_corners[0].y;
-  for (uint32_t i = 1; i < corners_to_process; i++) {
-    double sum = working_corners[i].x + working_corners[i].y;
+  // Selecting the strongest four peaks can discard the starting corner and
+  // change the document bounds. Label the final quadrilateral again rather
+  // than inheriting the first surviving peak as top-left.
+  Point2D selected[4] = {
+    {sorted_corners.tl_x, sorted_corners.tl_y},
+    {sorted_corners.tr_x, sorted_corners.tr_y},
+    {sorted_corners.br_x, sorted_corners.br_y},
+    {sorted_corners.bl_x, sorted_corners.bl_y}
+  };
+  Point2D ordered[4];
+  sorted_corners =
+    sort_corners(width, height, width, height, selected, 4, ordered);
+
+  free(sorted_result);
+
+#ifdef DEBUG_LOGGING
+  // Print peaks for debugging
+  fprintf(stderr, "Peaks:\n");
+  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.tl_x, sorted_corners.tl_y);
+  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.tr_x, sorted_corners.tr_y);
+  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.br_x, sorted_corners.br_y);
+  fprintf(stderr, "%.0f,%.0f\n", sorted_corners.bl_x, sorted_corners.bl_y);
+  // Draw corner peaks on the grayscale and resized image
+  for (uint32_t i = 0; i < peaks->count; i++) {
+    // Print peak coordinates for debugging
+    fprintf(
+      stderr,
+      "Peak %u: (%.1f, %.1f)\n",
+      i,
+      peaks->points[i].x,
+      peaks->points[i].y
+    );
+    fcv_draw_circle(
+      out_width,
+      out_height,
+      4,        // GRAY as RGBA (each value is the same)
+      "FFFF00", // Yellow color for corners
+      2,        // Radius
+      peaks->points[i].x,
+      peaks->points[i].y,
+      (uint8_t *)resized_image
+    );
+  }
+
+  // Draw sorted corners with different colors to distinguish from peaks.
+  // Reuse the scale variables from above.
+  // (Inverted for drawing on resized image.)
+  double draw_scale_x = (double)out_width / width;
+  double draw_scale_y = (double)out_height / height;
+
+  // Draw top-left corner in red
+  fcv_draw_circle(
+    out_width,
+    out_height,
+    4,
+    "FF0000", // Red color for top-left
+    3,        // Slightly larger radius to distinguish from peaks
+    sorted_corners.tl_x * draw_scale_x,
+    sorted_corners.tl_y * draw_scale_y,
+    (uint8_t *)resized_image
+  );
+
+  // Draw top-right corner in green
+  fcv_draw_circle(
+    out_width,
+    out_height,
+    4,
+    "00FF00", // Green color for top-right
+    3,
+    sorted_corners.tr_x * draw_scale_x,
+    sorted_corners.tr_y * draw_scale_y,
+    (uint8_t *)resized_image
+  );
+
+  // Draw bottom-right corner in blue
+  fcv_draw_circle(
+    out_width,
+    out_height,
+    4,
+    "0000FF", // Blue color for bottom-right
+    3,
+    sorted_corners.br_x * draw_scale_x,
+    sorted_corners.br_y * draw_scale_y,
+    (uint8_t *)resized_image
+  );
+
+  // Draw bottom-left corner in magenta
+  fcv_draw_circle(
+    out_width,
+    out_height,
+    4,
+    "FF00FF", // Magenta color for bottom-left
+    3,
+    sorted_corners.bl_x * draw_scale_x,
+    sorted_corners.bl_y * draw_scale_y,
+    (uint8_t *)resized_image
+  );
+
+  Image corners_img = {
+    .width = out_width,
+    .height = out_height,
+    .channels = 4,
+    .data = resized_image
+  };
+  write_debug_img(corners_img, "temp_7_corners.png");
+  free((void *)resized_image);
+#endif
+
+  free(peaks);
+  return sorted_corners;
+}
+
+/**
+ * Wrapper function that allocates corners on heap for FFI.
+ */
+Corners *
+fcv_detect_corners_ptr(const uint8_t *image, int32_t width, int32_t height) {
+  Corners corners = fcv_detect_corners(image, width, height);
+  Corners *result = malloc(sizeof(Corners));
+  if (result == NULL) {
+    return NULL;
+  }
+  *result = corners;
+  return result;
+}
+// File: src/corner_peaks.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "1_types.h"
+#include "conversion.h"
+#include "corner_peaks.h"
+#else
+#include "flatcv.h"
+#endif
+
+static bool is_local_maximum(
+  uint8_t const *data,
+  uint32_t width,
+  uint32_t height,
+  uint32_t x,
+  uint32_t y,
+  uint32_t channel
+) {
+  if (x == 0 || y == 0 || x >= width - 1 || y >= height - 1) {
+    return false;
+  }
+
+  uint32_t center_idx = (y * width + x) * 2 + channel;
+  uint8_t center_val = data[center_idx];
+
+  if (center_val == 0) {
+    return false;
+  }
+
+  for (int32_t dy = -1; dy <= 1; dy++) {
+    for (int32_t dx = -1; dx <= 1; dx++) {
+      if (dx == 0 && dy == 0) {
+        continue;
+      }
+
+      uint32_t neighbor_idx = ((y + dy) * width + (x + dx)) * 2 + channel;
+      if (data[neighbor_idx] > center_val) {
+        return false;
+      }
+    }
+  }
+
+  return true;
+}
+
+static double euclidean_distance(Point2D a, Point2D b) {
+  double dx = a.x - b.x;
+  double dy = a.y - b.y;
+  return sqrt(dx * dx + dy * dy);
+}
+
+/** Detect corner peaks in the image.
+ * This function finds local maxima in the corner response image
+ * and filters them based on specified thresholds.
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param data Pointer to the 2 channel (w and q) corner response data.
+ * @param min_distance Minimum distance between peaks.
+ * @param accuracy_thresh Threshold for accuracy measure (w).
+ * @param roundness_thresh Threshold for roundness measure (q).
+ * @return Pointer to CornerPeaks structure containing detected peaks.
+ */
+CornerPeaks *fcv_corner_peaks(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *data,
+  uint32_t min_distance,
+  double accuracy_thresh,
+  double roundness_thresh
+) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Validate thresholds
+  if (!isfinite(accuracy_thresh) || !isfinite(roundness_thresh)) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+
+  // Check for overflow in candidates allocation
+  if (num_pixels > SIZE_MAX / sizeof(Point2D)) {
+    return NULL;
+  }
+
+  Point2D *candidates = malloc(sizeof(Point2D) * num_pixels);
+  if (!candidates) {
+    return NULL;
+  }
+
+  uint32_t candidate_count = 0;
+
+  for (uint32_t y = 1; y < height - 1; y++) {
+    for (uint32_t x = 1; x < width - 1; x++) {
+      uint32_t idx = (y * width + x) * 2;
+
+      // Convert normalized values (0-255) back to 0-1 range
+      double w = data[idx] / 255.0;     // accuracy measure
+      double q = data[idx + 1] / 255.0; // roundness measure
+
+      if (q > roundness_thresh && w > accuracy_thresh &&
+          is_local_maximum(data, width, height, x, y, 0)) {
+        candidates[candidate_count].x = (double)x;
+        candidates[candidate_count].y = (double)y;
+        candidate_count++;
+      }
+    }
+  }
+
+  if (candidate_count == 0) {
+    free(candidates);
+    CornerPeaks *result = malloc(sizeof(CornerPeaks));
+    if (result) {
+      result->points = NULL;
+      result->count = 0;
+    }
+    return result;
+  }
+
+  bool *rejected = calloc(candidate_count, sizeof(bool));
+  if (!rejected) {
+    free(candidates);
+    return NULL;
+  }
+
+  for (uint32_t i = 0; i < candidate_count; i++) {
+    if (rejected[i]) {
+      continue;
+    }
+
+    for (uint32_t j = i + 1; j < candidate_count; j++) {
+      if (rejected[j]) {
+        continue;
+      }
+
+      if (euclidean_distance(candidates[i], candidates[j]) < min_distance) {
+        uint32_t i_idx =
+          ((uint32_t)candidates[i].y * width + (uint32_t)candidates[i].x) * 2;
+        uint32_t j_idx =
+          ((uint32_t)candidates[j].y * width + (uint32_t)candidates[j].x) * 2;
+
+        if (data[i_idx] >= data[j_idx]) {
+          rejected[j] = true;
+        }
+        else {
+          rejected[i] = true;
+          break;
+        }
+      }
+    }
+  }
+
+  uint32_t final_count = 0;
+  for (uint32_t i = 0; i < candidate_count; i++) {
+    if (!rejected[i]) {
+      final_count++;
+    }
+  }
+
+  Point2D *final_points = malloc(sizeof(Point2D) * final_count);
+  if (!final_points) {
+    free(candidates);
+    free(rejected);
+    return NULL;
+  }
+
+  uint32_t idx = 0;
+  for (uint32_t i = 0; i < candidate_count; i++) {
+    if (!rejected[i]) {
+      final_points[idx] = candidates[i];
+      idx++;
+    }
+  }
+
+  free(candidates);
+  free(rejected);
+
+  CornerPeaks *result = malloc(sizeof(CornerPeaks));
+  if (!result) {
+    free(final_points);
+    return NULL;
+  }
+
+  result->points = final_points;
+  result->count = final_count;
+
+  return result;
+}
+// File: src/crop.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "crop.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Crop an image.
+ *
+ * @param width Width of the original image.
+ * @param height Height of the original image.
+ * @param channels Number of channels in the image.
+ * @param data Pointer to the pixel data.
+ * @param x The x-coordinate of the top-left corner of the crop area.
+ * @param y The y-coordinate of the top-left corner of the crop area.
+ * @param new_width The width of the crop area.
+ * @param new_height The height of the crop area.
+ * @return Pointer to the new cropped image data.
+ */
+uint8_t *fcv_crop(
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  uint8_t const *const data,
+  uint32_t x,
+  uint32_t y,
+  uint32_t new_width,
+  uint32_t new_height
+) {
+  if (!data) {
+    return NULL;
+  }
+
+  if (width == 0 || height == 0 || channels == 0 || new_width == 0 ||
+      new_height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow in x + new_width and y + new_height
+  if (x > UINT32_MAX - new_width || y > UINT32_MAX - new_height) {
+    return NULL;
+  }
+
+  if (x + new_width > width || y + new_height > height) {
+    fprintf(stderr, "Crop area is outside the original image bounds.\n");
+    return NULL;
+  }
+
+  // Check for overflow in memory allocation: new_width * new_height * channels
+  if (new_width > SIZE_MAX / new_height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)new_width * new_height;
+  if (num_pixels > SIZE_MAX / channels) {
+    return NULL;
+  }
+  size_t alloc_size = num_pixels * channels;
+
+  uint8_t *cropped_data = malloc(alloc_size);
+  if (!cropped_data) {
+    fprintf(stderr, "Memory allocation failed for cropped image.\n");
+    return NULL;
+  }
+
+  size_t row_bytes = (size_t)new_width * channels;
+  for (uint32_t i = 0; i < new_height; ++i) {
+    size_t src_index = ((size_t)(y + i) * width + x) * channels;
+    size_t dst_index = (size_t)i * row_bytes;
+    memcpy(cropped_data + dst_index, data + src_index, row_bytes);
+  }
+
+  return cropped_data;
+}
+// File: src/draw.c
+#include <assert.h>
+#include <limits.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "draw.h"
+#include "parse_hex_color.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Helper function to set a pixel to specified color (for circle drawing)
+ */
+void fcv_set_circle_pixel(
+  uint8_t *data,
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  int32_t px,
+  int32_t py,
+  uint8_t r,
+  uint8_t g,
+  uint8_t b
+) {
+  if (!data || width == 0 || height == 0 || channels == 0) {
+    return;
+  }
+  if (px >= 0 && px < (int32_t)width && py >= 0 && py < (int32_t)height) {
+    size_t pixel_index = ((size_t)py * width + px) * channels;
+
+    if (channels == 1) {
+      // Grayscale: use luminance formula
+      data[pixel_index] = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);
+    }
+    else if (channels >= 3) {
+      data[pixel_index] = r;     // R
+      data[pixel_index + 1] = g; // G
+      data[pixel_index + 2] = b; // B
+      if (channels == 4) {
+        data[pixel_index + 3] = 255; // A
+      }
+    }
+  }
+}
+
+/**
+ * Helper function to draw 8 symmetric points of a circle
+ */
+void fcv_draw_circle_points(
+  uint8_t *data,
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  int32_t cx,
+  int32_t cy,
+  int32_t x,
+  int32_t y,
+  uint8_t r,
+  uint8_t g,
+  uint8_t b
+) {
+  fcv_set_circle_pixel(data, width, height, channels, cx + x, cy + y, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx - x, cy + y, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx + x, cy - y, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx - x, cy - y, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx + y, cy + x, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx - y, cy + x, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx + y, cy - x, r, g, b);
+  fcv_set_circle_pixel(data, width, height, channels, cx - y, cy - x, r, g, b);
+}
+
+/**
+ * Helper function to fill horizontal lines for a filled circle (disk)
+ */
+void fcv_fill_disk_lines(
+  uint8_t *data,
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  int32_t cx,
+  int32_t cy,
+  int32_t x,
+  int32_t y,
+  uint8_t r,
+  uint8_t g,
+  uint8_t b
+) {
+  // Fill horizontal lines at y offsets
+  for (int32_t i = cx - x; i <= cx + x; i++) {
+    fcv_set_circle_pixel(data, width, height, channels, i, cy + y, r, g, b);
+    fcv_set_circle_pixel(data, width, height, channels, i, cy - y, r, g, b);
+  }
+
+  // Fill horizontal lines at x offsets
+  // (avoid duplicating center line when x == y)
+  if (x != y) {
+    for (int32_t i = cx - y; i <= cx + y; i++) {
+      fcv_set_circle_pixel(data, width, height, channels, i, cy + x, r, g, b);
+      fcv_set_circle_pixel(data, width, height, channels, i, cy - x, r, g, b);
+    }
+  }
+}
+
+/**
+ * Draw a circle on an image using Bresenham's circle algorithm
+ * (modifies in-place).
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param channels Number of channels in the image (1, 3, or 4).
+ * @param hex_color Hex color code (e.g., "FF0000" for red, "#00FF00" for
+ * green).
+ * @param radius Radius of the circle.
+ * @param center_x X coordinate of the circle center.
+ * @param center_y Y coordinate of the circle center.
+ * @param data Pointer to the pixel data (modified in-place).
+ */
+void fcv_draw_circle(
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  const char *hex_color,
+  double radius,
+  double center_x,
+  double center_y,
+  uint8_t *data
+) {
+  if (!data || !hex_color || width == 0 || height == 0 || channels == 0) {
+    return;
+  }
+
+  // Validate radius and center coordinates
+  if (radius < 0 || !isfinite(radius) || !isfinite(center_x) ||
+      !isfinite(center_y)) {
+    return;
+  }
+
+  // Reject excessive radius to prevent integer overflow in Bresenham algorithm
+  if (radius > INT32_MAX / 2) {
+    return;
+  }
+
+  // Parse color
+  uint8_t r, g, b;
+  fcv_parse_hex_color(hex_color, &r, &g, &b);
+
+  int32_t radius_int32_t = (int32_t)radius;
+  int32_t cx = (int32_t)center_x;
+  int32_t cy = (int32_t)center_y;
+
+  // Bresenham's circle algorithm
+  int32_t x = 0;
+  int32_t y = radius_int32_t;
+  int32_t d = 3 - 2 * radius_int32_t;
+
+  // Initial points
+  fcv_draw_circle_points(data, width, height, channels, cx, cy, x, y, r, g, b);
+
+  while (y >= x) {
+    x++;
+    if (d > 0) {
+      y--;
+      d = d + 4 * (x - y) + 10;
+    }
+    else {
+      d = d + 4 * x + 6;
+    }
+    fcv_draw_circle_points(
+      data,
+      width,
+      height,
+      channels,
+      cx,
+      cy,
+      x,
+      y,
+      r,
+      g,
+      b
+    );
+  }
+}
+
+/**
+ * Draw a filled circle (disk) on an image using Bresenham's circle
+ * algorithm (modifies in-place).
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param channels Number of channels in the image (1, 3, or 4).
+ * @param hex_color Hex color code (e.g., "FF0000" for red, "#00FF00" for
+ * green).
+ * @param radius Radius of the disk.
+ * @param center_x X coordinate of the disk center.
+ * @param center_y Y coordinate of the disk center.
+ * @param data Pointer to the pixel data (modified in-place).
+ */
+void fcv_draw_disk(
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  const char *hex_color,
+  double radius,
+  double center_x,
+  double center_y,
+  uint8_t *data
+) {
+  if (!data || !hex_color || width == 0 || height == 0 || channels == 0) {
+    return;
+  }
+
+  // Validate radius and center coordinates
+  if (radius < 0 || !isfinite(radius) || !isfinite(center_x) ||
+      !isfinite(center_y)) {
+    return;
+  }
+
+  // Reject excessive radius to prevent integer overflow in Bresenham algorithm
+  if (radius > INT32_MAX / 2) {
+    return;
+  }
+
+  // Parse color
+  uint8_t r, g, b;
+  fcv_parse_hex_color(hex_color, &r, &g, &b);
+
+  int32_t radius_int32_t = (int32_t)radius;
+  int32_t cx = (int32_t)center_x;
+  int32_t cy = (int32_t)center_y;
+
+  // Bresenham's circle algorithm for filled disk
+  int32_t x = 0;
+  int32_t y = radius_int32_t;
+  int32_t d = 3 - 2 * radius_int32_t;
+
+  // Initial filled lines
+  fcv_fill_disk_lines(data, width, height, channels, cx, cy, x, y, r, g, b);
+
+  while (y >= x) {
+    x++;
+    if (d > 0) {
+      y--;
+      d = d + 4 * (x - y) + 10;
+    }
+    else {
+      d = d + 4 * x + 6;
+    }
+    fcv_fill_disk_lines(data, width, height, channels, cx, cy, x, y, r, g, b);
+  }
+}
+
+/**
+ * Add a colored border around an image.
+ * Returns a new image with the border added.
+ *
+ * @param width Width of the input image.
+ * @param height Height of the input image.
+ * @param channels Number of channels in the image (1, 3, or 4).
+ * @param hex_color Hex color code for the border (e.g., "FF0000" for red).
+ * @param border_width Width of the border in pixels.
+ * @param input_data Pointer to the input pixel data.
+ * @param output_width Pointer to store the output image width.
+ * @param output_height Pointer to store the output image height.
+ * @return Pointer to the new image data with border, or NULL on failure.
+ */
+uint8_t *fcv_add_border(
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  const char *hex_color,
+  uint32_t border_width,
+  uint8_t *input_data,
+  uint32_t *output_width,
+  uint32_t *output_height
+) {
+  if (!input_data || !output_width || !output_height || !hex_color ||
+      border_width == 0 || channels == 0) {
+    return NULL;
+  }
+
+  if (width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow in output dimensions
+  if (border_width > (UINT32_MAX - width) / 2 ||
+      border_width > (UINT32_MAX - height) / 2) {
+    return NULL;
+  }
+
+  // Parse border color
+  uint8_t r, g, b;
+  fcv_parse_hex_color(hex_color, &r, &g, &b);
+
+  // Calculate output dimensions
+  *output_width = width + 2 * border_width;
+  *output_height = height + 2 * border_width;
+
+  // Check for overflow in memory allocation: output_width * output_height *
+  // channels
+  if (*output_width > SIZE_MAX / *output_height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)(*output_width) * (*output_height);
+  if (num_pixels > SIZE_MAX / channels) {
+    return NULL;
+  }
+  size_t output_size = num_pixels * channels;
+
+  uint8_t *output_data = malloc(output_size);
+  if (!output_data) {
+    return NULL;
+  }
+
+  // Fill the entire output image with border color
+  for (uint32_t y = 0; y < *output_height; y++) {
+    for (uint32_t x = 0; x < *output_width; x++) {
+      uint32_t pixel_index = (y * *output_width + x) * channels;
+
+      if (channels == 1) {
+        // Grayscale: use luminance formula
+        output_data[pixel_index] = (uint8_t)(0.299 * r + 0.587 * g + 0.114 * b);
+      }
+      else if (channels >= 3) {
+        output_data[pixel_index] = r;     // R
+        output_data[pixel_index + 1] = g; // G
+        output_data[pixel_index + 2] = b; // B
+        if (channels == 4) {
+          output_data[pixel_index + 3] = 255; // A
+        }
+      }
+    }
+  }
+
+  // Copy the original image into the center
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      uint32_t src_index = (y * width + x) * channels;
+      uint32_t dst_index =
+        ((y + border_width) * *output_width + (x + border_width)) * channels;
+
+      for (uint32_t c = 0; c < channels; c++) {
+        output_data[dst_index + c] = input_data[src_index + c];
+      }
+    }
+  }
+
+  return output_data;
+}
+// File: src/exif.c
+#include <stdint.h>
+#include <stdio.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "exif.h"
+#endif
+
+static uint16_t read_u16(FILE *file, int is_little_endian) {
+  uint8_t buf[2];
+  if (fread(buf, 1, 2, file) != 2) {
+    return 0;
+  }
+  if (is_little_endian) {
+    return (uint16_t)(buf[0] | (buf[1] << 8));
+  }
+  return (uint16_t)((buf[0] << 8) | buf[1]);
+}
+
+static uint32_t read_u32(FILE *file, int is_little_endian) {
+  uint8_t buf[4];
+  if (fread(buf, 1, 4, file) != 4) {
+    return 0;
+  }
+  if (is_little_endian) {
+    return (uint32_t)(buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24));
+  }
+  return (uint32_t)((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]);
+}
+
+/**
+ * Parse the orientation tag from a TIFF/EXIF block. `tiff_start` must point
+ * to the first byte of the TIFF header (the "II" / "MM" byte-order marker).
+ * Returns the orientation value (1-8) or 1 if not found.
+ */
+static int32_t read_orientation_from_tiff(FILE *file, long tiff_start) {
+  fseek(file, tiff_start, SEEK_SET);
+
+  uint8_t tiff_header[2];
+  if (fread(tiff_header, 1, 2, file) != 2) {
+    return 1;
+  }
+
+  int is_little_endian = (tiff_header[0] == 'I' && tiff_header[1] == 'I');
+  int is_big_endian = (tiff_header[0] == 'M' && tiff_header[1] == 'M');
+
+  if (!is_little_endian && !is_big_endian) {
+    return 1;
+  }
+
+  uint16_t magic = read_u16(file, is_little_endian);
+  if (magic != 42) {
+    return 1;
+  }
+
+  uint32_t ifd_offset = read_u32(file, is_little_endian);
+
+  fseek(file, tiff_start + ifd_offset, SEEK_SET);
+
+  uint16_t num_entries = read_u16(file, is_little_endian);
+
+  for (uint16_t i = 0; i < num_entries; i++) {
+    uint16_t tag = read_u16(file, is_little_endian);
+    (void)read_u16(file, is_little_endian); // Skip type
+    (void)read_u32(file, is_little_endian); // Skip count
+
+    if (tag == 0x0112) { // Orientation
+      return (int32_t)read_u16(file, is_little_endian);
+    }
+    else {
+      fseek(file, 4, SEEK_CUR); // Skip value/offset
+    }
+  }
+
+  return 1;
+}
+
+/**
+ * Read the EXIF orientation from a JPEG file. `file` must be positioned just
+ * after the two-byte SOI marker (0xFFD8).
+ */
+static int32_t get_jpeg_orientation(FILE *file) {
+  uint8_t marker[2];
+  while (fread(marker, 1, 2, file) == 2) {
+    if (marker[0] != 0xFF) {
+      break;
+    }
+
+    if (marker[1] == 0xD9 || marker[1] == 0xDA) { // EOI or SOS
+      break;
+    }
+
+    uint16_t size = read_u16(file, 0); // Markers are always big-endian
+
+    if (marker[1] == 0xE1) { // APP1 - EXIF
+      uint8_t exif_header[6];
+      if (fread(exif_header, 1, 6, file) != 6) {
+        break;
+      }
+
+      if (memcmp(exif_header, "Exif\0\0", 6) != 0) {
+        fseek(file, size - 2 - 6, SEEK_CUR);
+        continue;
+      }
+
+      return read_orientation_from_tiff(file, ftell(file));
+    }
+    else {
+      fseek(file, size - 2, SEEK_CUR);
+    }
+  }
+
+  return 1;
+}
+
+/**
+ * Read the EXIF orientation from a PNG file. `file` must be positioned just
+ * after the eight-byte PNG signature.
+ *
+ * EXIF data is stored in an `eXIf` chunk whose payload is a raw TIFF block
+ * (no leading "Exif\0\0" prefix, unlike JPEG's APP1 segment).
+ */
+static int32_t get_png_orientation(FILE *file) {
+  uint8_t type[4];
+  while (1) {
+    uint32_t length = read_u32(file, 0); // Chunk fields are big-endian
+    if (fread(type, 1, 4, file) != 4) {
+      break;
+    }
+
+    if (memcmp(type, "eXIf", 4) == 0) {
+      return read_orientation_from_tiff(file, ftell(file));
+    }
+
+    if (memcmp(type, "IEND", 4) == 0) {
+      break;
+    }
+
+    fseek(file, (long)length + 4, SEEK_CUR); // Skip chunk data + CRC
+  }
+
+  return 1;
+}
+
+int32_t fcv_get_exif_orientation(char const *filename) {
+  FILE *file = fopen(filename, "rb");
+  if (!file) {
+    return 1;
+  }
+
+  uint8_t signature[8];
+  size_t read_count = fread(signature, 1, 8, file);
+
+  int32_t orientation = 1;
+
+  static uint8_t const png_signature[8] =
+    {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
+
+  if (read_count >= 2 && signature[0] == 0xFF && signature[1] == 0xD8) {
+    fseek(file, 2, SEEK_SET);
+    orientation = get_jpeg_orientation(file);
+  }
+  else if (read_count == 8 && memcmp(signature, png_signature, 8) == 0) {
+    orientation = get_png_orientation(file);
+  }
+
+  fclose(file);
+  return orientation;
+}
+// File: src/extract_document.c
+#include <math.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "perspectivetransform.h"
+#else
+#include "flatcv.h"
+#endif
+
+uint8_t *fcv_extract_document(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const data,
+  uint32_t output_width,
+  uint32_t output_height
+) {
+  if (!data) {
+    fprintf(stderr, "Error: NULL image data\n");
+    return NULL;
+  }
+
+  // Step 1: Detect corners of the document
+  Corners detected_corners = fcv_detect_corners(data, width, height);
+
+  // Step 2: Define destination corners (rectangular output)
+  Corners dst_corners = {
+    .tl_x = 0.0,
+    .tl_y = 0.0,
+    .tr_x = (double)(output_width - 1),
+    .tr_y = 0.0,
+    .br_x = (double)(output_width - 1),
+    .br_y = (double)(output_height - 1),
+    .bl_x = 0.0,
+    .bl_y = (double)(output_height - 1)
+  };
+
+  // Step 3: Calculate perspective transformation matrix
+  // Note: We need to map FROM destination TO source for the inverse transform
+  Matrix3x3 *transform_matrix =
+    fcv_calculate_perspective_transform(&dst_corners, &detected_corners);
+
+  if (!transform_matrix) {
+    fprintf(stderr, "Error: Failed to calculate perspective transform\n");
+    return NULL;
+  }
+
+  // Step 4: Apply the transformation
+  uint8_t *result = fcv_apply_matrix_3x3(
+    width,
+    height,
+    (uint8_t *)data,
+    output_width,
+    output_height,
+    transform_matrix
+  );
+
+  // Free the transformation matrix if it's not the identity matrix
+  if (transform_matrix &&
+      !(transform_matrix->m00 == 1.0 && transform_matrix->m01 == 0.0 &&
+        transform_matrix->m02 == 0.0 && transform_matrix->m10 == 0.0 &&
+        transform_matrix->m11 == 1.0 && transform_matrix->m12 == 0.0 &&
+        transform_matrix->m20 == 0.0 && transform_matrix->m21 == 0.0 &&
+        transform_matrix->m22 == 1.0)) {
+    free(transform_matrix);
+  }
+
+  return result;
+}
+
+uint8_t *fcv_extract_document_auto(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const data,
+  uint32_t *output_width,
+  uint32_t *output_height
+) {
+  if (!data || !output_width || !output_height) {
+    fprintf(stderr, "Error: NULL parameters\n");
+    return NULL;
+  }
+
+  // Step 1: Detect corners of the document
+  Corners detected_corners = fcv_detect_corners(data, width, height);
+
+  // Step 2: Calculate optimal output dimensions based on detected corners
+  // Calculate distances between corners to determine aspect ratio
+  double top_width = sqrt(
+    pow(detected_corners.tr_x - detected_corners.tl_x, 2) +
+    pow(detected_corners.tr_y - detected_corners.tl_y, 2)
+  );
+  double bottom_width = sqrt(
+    pow(detected_corners.br_x - detected_corners.bl_x, 2) +
+    pow(detected_corners.br_y - detected_corners.bl_y, 2)
+  );
+  double left_height = sqrt(
+    pow(detected_corners.bl_x - detected_corners.tl_x, 2) +
+    pow(detected_corners.bl_y - detected_corners.tl_y, 2)
+  );
+  double right_height = sqrt(
+    pow(detected_corners.br_x - detected_corners.tr_x, 2) +
+    pow(detected_corners.br_y - detected_corners.tr_y, 2)
+  );
+
+  // Use maximum dimensions to preserve detail
+  double max_width = fmax(top_width, bottom_width);
+  double max_height = fmax(left_height, right_height);
+
+  // Round to reasonable dimensions
+  *output_width = (uint32_t)(max_width + 0.5);
+  *output_height = (uint32_t)(max_height + 0.5);
+
+  // Ensure minimum reasonable size
+  if (*output_width < 100) {
+    *output_width = 100;
+  }
+  if (*output_height < 100) {
+    *output_height = 100;
+  }
+
+  // Step 3: Define destination corners (rectangular output)
+  Corners dst_corners = {
+    .tl_x = 0.0,
+    .tl_y = 0.0,
+    .tr_x = (double)(*output_width - 1),
+    .tr_y = 0.0,
+    .br_x = (double)(*output_width - 1),
+    .br_y = (double)(*output_height - 1),
+    .bl_x = 0.0,
+    .bl_y = (double)(*output_height - 1)
+  };
+
+  // Step 4: Calculate perspective transformation matrix
+  // Note: We need to map FROM destination TO source for the inverse transform
+  Matrix3x3 *transform_matrix =
+    fcv_calculate_perspective_transform(&dst_corners, &detected_corners);
+
+  if (!transform_matrix) {
+    fprintf(stderr, "Error: Failed to calculate perspective transform\n");
+    return NULL;
+  }
+
+  // Step 5: Apply the transformation
+  uint8_t *result = fcv_apply_matrix_3x3(
+    width,
+    height,
+    (uint8_t *)data,
+    *output_width,
+    *output_height,
+    transform_matrix
+  );
+
+  // Free the transformation matrix if it's not the identity matrix
+  if (transform_matrix &&
+      !(transform_matrix->m00 == 1.0 && transform_matrix->m01 == 0.0 &&
+        transform_matrix->m02 == 0.0 && transform_matrix->m10 == 0.0 &&
+        transform_matrix->m11 == 1.0 && transform_matrix->m12 == 0.0 &&
+        transform_matrix->m20 == 0.0 && transform_matrix->m21 == 0.0 &&
+        transform_matrix->m22 == 1.0)) {
+    free(transform_matrix);
+  }
+
+  return result;
+}
+// File: src/flip.c
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "flip.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Flip an image horizontally (mirror along vertical axis).
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param data Pointer to the pixel data.
+ * @return Pointer to the flipped image data.
+ */
+uint8_t *
+fcv_flip_x(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *flipped_data = malloc(img_length_byte);
+
+  if (!flipped_data) { // Memory allocation failed
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index = ((size_t)y * width + (width - 1 - x)) * 4;
+
+      flipped_data[dst_index] = data[src_index];         // R
+      flipped_data[dst_index + 1] = data[src_index + 1]; // G
+      flipped_data[dst_index + 2] = data[src_index + 2]; // B
+      flipped_data[dst_index + 3] = data[src_index + 3]; // A
+    }
+  }
+
+  return flipped_data;
+}
+
+/**
+ * Flip an image vertically (mirror along horizontal axis).
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param data Pointer to the pixel data.
+ * @return Pointer to the flipped image data.
+ */
+uint8_t *
+fcv_flip_y(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *flipped_data = malloc(img_length_byte);
+
+  if (!flipped_data) { // Memory allocation failed
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index = ((size_t)(height - 1 - y) * width + x) * 4;
+
+      flipped_data[dst_index] = data[src_index];         // R
+      flipped_data[dst_index + 1] = data[src_index + 1]; // G
+      flipped_data[dst_index + 2] = data[src_index + 2]; // B
+      flipped_data[dst_index + 3] = data[src_index + 3]; // A
+    }
+  }
+
+  return flipped_data;
+}
+
+/**
+ * Transpose an image (flip along main diagonal).
+ */
+uint8_t *
+fcv_transpose(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *transposed_data = malloc(img_length_byte);
+
+  if (!transposed_data) {
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index = ((size_t)x * height + y) * 4;
+
+      transposed_data[dst_index] = data[src_index];
+      transposed_data[dst_index + 1] = data[src_index + 1];
+      transposed_data[dst_index + 2] = data[src_index + 2];
+      transposed_data[dst_index + 3] = data[src_index + 3];
+    }
+  }
+
+  return transposed_data;
+}
+
+/**
+ * Transverse an image (flip along anti-diagonal).
+ */
+uint8_t *
+fcv_transverse(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *transposed_data = malloc(img_length_byte);
+
+  if (!transposed_data) {
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index =
+        ((size_t)(width - 1 - x) * height + (height - 1 - y)) * 4;
+
+      transposed_data[dst_index] = data[src_index];
+      transposed_data[dst_index + 1] = data[src_index + 1];
+      transposed_data[dst_index + 2] = data[src_index + 2];
+      transposed_data[dst_index + 3] = data[src_index + 3];
+    }
+  }
+
+  return transposed_data;
+}
+// File: src/flood_fill.c
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "1_types.h"
+#include "flood_fill.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Flood fill segmentation
+ * using a seeded breadth-first traversal with threshold-based inclusion
+ *
+ * Starting from each marker, expands across neighboring pixels whose
+ * grayscale values stay within the given threshold of that marker's value.
+ * Every reached pixel is set to `new_value`.
+ * Each marker is filled independently (relative to its own seed value),
+ * so the result is the union of all individual fills.
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param grayscale_data Pointer to the input grayscale image data.
+ * @param markers Seeds from which to start the flood fill.
+ * @param num_markers Number of seeds.
+ * @param threshold Maximum absolute grayscale difference from the seed value.
+ * @param new_value Replacement value written to filled pixels.
+ * @param connectivity 4 (edge neighbors) or 8 (edge and diagonal neighbors).
+ * @return Pointer to the filled image data,
+ *   or NULL on invalid input or allocation failure.
+ */
+uint8_t *fcv_flood_fill(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const grayscale_data,
+  Point2D const *markers,
+  uint32_t num_markers,
+  uint8_t threshold,
+  uint8_t new_value,
+  uint8_t connectivity
+) {
+  if (!grayscale_data || !markers || num_markers == 0 || width == 0 ||
+      height == 0 || width > INT32_MAX || height > INT32_MAX ||
+      (connectivity != 4 && connectivity != 8)) {
+    return NULL;
+  }
+
+  // Range-check as double before casting: out-of-range float-to-int
+  // conversion is undefined behavior, and truncation would map e.g. -0.5 to 0.
+  for (uint32_t m = 0; m < num_markers; m++) {
+    double x = markers[m].x;
+    double y = markers[m].y;
+    if (!isfinite(x) || !isfinite(y) || x < 0 || y < 0 || x >= (double)width ||
+        y >= (double)height) {
+      return NULL;
+    }
+  }
+
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t img_length_px = (size_t)width * height;
+  if (img_length_px > SIZE_MAX / sizeof(size_t)) {
+    return NULL;
+  }
+
+  uint8_t *output_data = malloc(img_length_px);
+  bool *visited = calloc(img_length_px, sizeof(bool));
+  // Each pixel is enqueued at most once per seed,
+  // so a linear (non-wrapping) queue of image size suffices.
+  // After a seed's traversal, it also holds exactly the visited pixels.
+  size_t *queue = malloc(img_length_px * sizeof(size_t));
+  if (!output_data || !visited || !queue) {
+    free(output_data);
+    free(visited);
+    free(queue);
+    return NULL;
+  }
+  memcpy(output_data, grayscale_data, img_length_px);
+
+  // First 4 entries are edge neighbors, last 4 are diagonals
+  int32_t const dx[] = {-1, 1, 0, 0, -1, 1, -1, 1};
+  int32_t const dy[] = {0, 0, -1, 1, -1, -1, 1, 1};
+
+  for (uint32_t m = 0; m < num_markers; m++) {
+    uint32_t seed_x = (uint32_t)markers[m].x;
+    uint32_t seed_y = (uint32_t)markers[m].y;
+    size_t seed_idx = (size_t)seed_y * width + seed_x;
+    uint8_t target_value = grayscale_data[seed_idx];
+
+    size_t head = 0;
+    size_t tail = 0;
+    queue[tail++] = seed_idx;
+    visited[seed_idx] = true;
+
+    while (head < tail) {
+      size_t idx = queue[head++];
+      output_data[idx] = new_value;
+
+      int32_t x = (int32_t)(idx % width);
+      int32_t y = (int32_t)(idx / width);
+
+      for (uint8_t d = 0; d < connectivity; d++) {
+        int32_t nx = x + dx[d];
+        int32_t ny = y + dy[d];
+        if (nx < 0 || nx >= (int32_t)width || ny < 0 || ny >= (int32_t)height) {
+          continue;
+        }
+
+        size_t neighbor_idx = (size_t)ny * width + (size_t)nx;
+        if (visited[neighbor_idx]) {
+          continue;
+        }
+
+        int32_t diff = (int32_t)grayscale_data[neighbor_idx] - target_value;
+        if (diff < 0) {
+          diff = -diff;
+        }
+        if (diff > threshold) {
+          continue;
+        }
+
+        visited[neighbor_idx] = true;
+        queue[tail++] = neighbor_idx;
+      }
+    }
+
+    // Reset only the pixels visited by this seed,
+    // so the next seed can traverse them relative to its own value
+    for (size_t i = 0; i < tail; i++) {
+      visited[queue[i]] = false;
+    }
+  }
+
+  free(queue);
+  free(visited);
+  return output_data;
+}
+// File: src/foerstner_corner.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "conversion.h"
+#include "perspectivetransform.h"
+#else
+#include "flatcv.h"
+#endif
+
+/** Implementation of the Foerstner corner measure response image.
+ * Expected input is a grayscale image.
+ */
+uint8_t *fcv_foerstner_corner(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const gray_data,
+  double sigma
+) {
+  if (!gray_data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Allocate memory for gradient images
+  double *grad_x = calloc(width * height, sizeof(double));
+  double *grad_y = calloc(width * height, sizeof(double));
+
+  if (!grad_x || !grad_y) {
+    free(grad_x);
+    free(grad_y);
+    return NULL;
+  }
+
+  // Compute image gradients using Sobel-like operators
+  for (uint32_t y = 1; y < height - 1; y++) {
+    for (uint32_t x = 1; x < width - 1; x++) {
+      uint32_t idx = y * width + x;
+
+      // Sobel X kernel: [-1 0 1; -2 0 2; -1 0 1]
+      double gx = 0.0;
+      gx += -1.0 * gray_data[(y - 1) * width + (x - 1)];
+      gx += 1.0 * gray_data[(y - 1) * width + (x + 1)];
+      gx += -2.0 * gray_data[y * width + (x - 1)];
+      gx += 2.0 * gray_data[y * width + (x + 1)];
+      gx += -1.0 * gray_data[(y + 1) * width + (x - 1)];
+      gx += 1.0 * gray_data[(y + 1) * width + (x + 1)];
+
+      // Sobel Y kernel: [-1 -2 -1; 0 0 0; 1 2 1]
+      double gy = 0.0;
+      gy += -1.0 * gray_data[(y - 1) * width + (x - 1)];
+      gy += -2.0 * gray_data[(y - 1) * width + x];
+      gy += -1.0 * gray_data[(y - 1) * width + (x + 1)];
+      gy += 1.0 * gray_data[(y + 1) * width + (x - 1)];
+      gy += 2.0 * gray_data[(y + 1) * width + x];
+      gy += 1.0 * gray_data[(y + 1) * width + (x + 1)];
+
+      grad_x[idx] = gx / 8.0; // Normalize
+      grad_y[idx] = gy / 8.0;
+    }
+  }
+
+  // Compute gradient products for structure tensor
+  double *Axx = malloc(width * height * sizeof(double));
+  double *Axy = malloc(width * height * sizeof(double));
+  double *Ayy = malloc(width * height * sizeof(double));
+
+  if (!Axx || !Axy || !Ayy) {
+    free(grad_x);
+    free(grad_y);
+    free(Axx);
+    free(Axy);
+    free(Ayy);
+    return NULL;
+  }
+
+  for (uint32_t i = 0; i < width * height; i++) {
+    Axx[i] = grad_x[i] * grad_x[i];
+    Axy[i] = grad_x[i] * grad_y[i];
+    Ayy[i] = grad_y[i] * grad_y[i];
+  }
+
+  // Apply Gaussian smoothing to structure tensor elements
+  // For simplicity, use a basic box filter approximation when sigma is small
+  int32_t kernel_size = (int32_t)(3 * sigma) | 1; // Ensure odd size
+  if (kernel_size < 3) {
+    kernel_size = 3;
+  }
+  int32_t half_kernel = kernel_size / 2;
+
+  double *Axx_smooth = calloc(width * height, sizeof(double));
+  double *Axy_smooth = calloc(width * height, sizeof(double));
+  double *Ayy_smooth = calloc(width * height, sizeof(double));
+
+  if (!Axx_smooth || !Axy_smooth || !Ayy_smooth) {
+    free(grad_x);
+    free(grad_y);
+    free(Axx);
+    free(Axy);
+    free(Ayy);
+    free(Axx_smooth);
+    free(Axy_smooth);
+    free(Ayy_smooth);
+    return NULL;
+  }
+
+  // Simple box filter smoothing
+  for (uint32_t y = half_kernel; y < height - half_kernel; y++) {
+    for (uint32_t x = half_kernel; x < width - half_kernel; x++) {
+      uint32_t idx = y * width + x;
+      double sum_xx = 0.0, sum_xy = 0.0, sum_yy = 0.0;
+      int32_t count = 0;
+
+      for (int32_t ky = -half_kernel; ky <= half_kernel; ky++) {
+        for (int32_t kx = -half_kernel; kx <= half_kernel; kx++) {
+          int32_t sample_idx = (y + ky) * width + (x + kx);
+          sum_xx += Axx[sample_idx];
+          sum_xy += Axy[sample_idx];
+          sum_yy += Ayy[sample_idx];
+          count++;
+        }
+      }
+
+      Axx_smooth[idx] = sum_xx / count;
+      Axy_smooth[idx] = sum_xy / count;
+      Ayy_smooth[idx] = sum_yy / count;
+    }
+  }
+
+  // Compute Foerstner measures w and q
+  uint8_t *result = malloc(width * height * 2); // 2 channels: w, q
+  if (!result) {
+    free(grad_x);
+    free(grad_y);
+    free(Axx);
+    free(Axy);
+    free(Ayy);
+    free(Axx_smooth);
+    free(Axy_smooth);
+    free(Ayy_smooth);
+    return NULL;
+  }
+
+  double max_w = 0.0, max_q = 0.0;
+  double *w_values = malloc(width * height * sizeof(double));
+  double *q_values = malloc(width * height * sizeof(double));
+  if (!w_values || !q_values) {
+    free(result);
+    free(w_values);
+    free(q_values);
+    free(grad_x);
+    free(grad_y);
+    free(Axx);
+    free(Axy);
+    free(Ayy);
+    free(Axx_smooth);
+    free(Axy_smooth);
+    free(Ayy_smooth);
+    return NULL;
+  }
+
+  // First pass: compute w and q values and find maximum for normalization
+  for (uint32_t i = 0; i < width * height; i++) {
+    double det_A =
+      Axx_smooth[i] * Ayy_smooth[i] - Axy_smooth[i] * Axy_smooth[i];
+    double trace_A = Axx_smooth[i] + Ayy_smooth[i];
+
+    double w = 0.0, q = 0.0;
+
+    if (fabs(trace_A) > 1e-10) { // Avoid division by zero
+      w = det_A / trace_A;
+      q = 4.0 * det_A / (trace_A * trace_A);
+    }
+
+    w_values[i] = fmax(0.0, w); // Ensure non-negative
+    q_values[i] = fmax(0.0, q);
+
+    if (w_values[i] > max_w) {
+      max_w = w_values[i];
+    }
+    if (q_values[i] > max_q) {
+      max_q = q_values[i];
+    }
+  }
+
+  // Second pass: normalize and convert to bytes
+  for (uint32_t i = 0; i < width * height; i++) {
+    uint8_t w_byte = 0, q_byte = 0;
+
+    if (max_w > 0.0) {
+      w_byte = (uint8_t)(255.0 * w_values[i] / max_w);
+    }
+    if (max_q > 0.0) {
+      q_byte = (uint8_t)(255.0 * q_values[i] / max_q);
+    }
+
+    result[i * 2] = w_byte;     // w measure
+    result[i * 2 + 1] = q_byte; // q measure
+  }
+
+  free(w_values);
+  free(q_values);
+
+  // Cleanup
+  free(grad_x);
+  free(grad_y);
+  free(Axx);
+  free(Axy);
+  free(Ayy);
+  free(Axx_smooth);
+  free(Axy_smooth);
+  free(Ayy_smooth);
+
+  return result;
+}
+// File: src/histogram.c
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "histogram.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Generate a histogram visualization image from input image data.
+ * For grayscale images, creates a single histogram.
+ * For RGB(A) images, creates overlapping histograms for each channel.
+ *
+ * @param width Width of the input image.
+ * @param height Height of the input image.
+ * @param channels Number of channels in the input image (1, 3, or 4).
+ * @param data Pointer to the input pixel data.
+ * @param out_width Pointer to store the output histogram width.
+ * @param out_height Pointer to store the output histogram height.
+ * @return Pointer to the histogram image data (RGBA format).
+ */
+uint8_t *fcv_generate_histogram(
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  uint8_t const *const data,
+  uint32_t *out_width,
+  uint32_t *out_height
+) {
+  if (!data || !out_width || !out_height) {
+    return NULL;
+  }
+
+  if (width == 0 || height == 0 || channels == 0) {
+    return NULL;
+  }
+
+  // Check for overflow in img_size calculation
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t img_size = (size_t)width * height;
+
+  // Validate that we can access all pixels
+  if (img_size > SIZE_MAX / channels) {
+    return NULL;
+  }
+
+  // Output histogram dimensions
+  const uint32_t hist_width = 256;  // One pixel per brightness value
+  const uint32_t hist_height = 200; // Fixed height for visualization
+
+  *out_width = hist_width;
+  *out_height = hist_height;
+
+  // Output size is fixed: 256 * 200 * 4 = 204800 bytes (no overflow possible)
+  size_t output_size = (size_t)hist_width * hist_height * 4;
+
+  // Allocate output image (initialized to black background)
+  uint8_t *output = calloc(output_size, sizeof(uint8_t));
+  if (!output) {
+    return NULL;
+  }
+
+  // Initialize with black background and full alpha
+  for (uint32_t i = 0; i < (*out_width) * (*out_height); i++) {
+    output[i * 4 + 3] = 255; // Alpha channel
+  }
+
+  // Count histograms for each channel
+  uint32_t hist_r[256] = {0};
+  uint32_t hist_g[256] = {0};
+  uint32_t hist_b[256] = {0};
+  uint32_t hist_gray[256] = {0};
+
+  bool is_grayscale = (channels == 1);
+  if (channels == 4) {
+    // Check if it's actually grayscale (all RGB channels equal)
+    is_grayscale = true;
+    for (size_t i = 0; i < img_size && is_grayscale; i++) {
+      size_t idx = i * 4;
+      if (data[idx] != data[idx + 1] || data[idx] != data[idx + 2]) {
+        is_grayscale = false;
+      }
+    }
+  }
+
+  if (is_grayscale) {
+    // Single channel or grayscale image
+    for (size_t i = 0; i < img_size; i++) {
+      uint8_t value;
+      if (channels == 1) {
+        value = data[i];
+      }
+      else {
+        // Use the red channel for grayscale RGBA
+        value = data[i * 4];
+      }
+      hist_gray[value]++;
+    }
+  }
+  else {
+    // RGB or RGBA image - count each channel
+    for (size_t i = 0; i < img_size; i++) {
+      size_t idx = i * channels;
+      hist_r[data[idx]]++;
+      if (channels >= 3) {
+        hist_g[data[idx + 1]]++;
+        hist_b[data[idx + 2]]++;
+      }
+    }
+  }
+
+  // Find maximum count for scaling
+  uint32_t max_count = 0;
+  if (is_grayscale) {
+    for (int i = 0; i < 256; i++) {
+      if (hist_gray[i] > max_count) {
+        max_count = hist_gray[i];
+      }
+    }
+  }
+  else {
+    for (int i = 0; i < 256; i++) {
+      if (hist_r[i] > max_count) {
+        max_count = hist_r[i];
+      }
+      if (channels >= 3) {
+        if (hist_g[i] > max_count) {
+          max_count = hist_g[i];
+        }
+        if (hist_b[i] > max_count) {
+          max_count = hist_b[i];
+        }
+      }
+    }
+  }
+
+  if (max_count == 0) {
+    return output; // Return black image if no data
+  }
+
+  // Draw histogram bars
+  for (int x = 0; x < 256; x++) {
+    if (is_grayscale) {
+      // Draw white histogram for grayscale
+      uint32_t bar_height = (hist_gray[x] * hist_height) / max_count;
+      for (uint32_t y = 0; y < bar_height; y++) {
+        uint32_t output_x = x;
+        uint32_t output_y = hist_height - 1 - y;
+        uint32_t output_idx = (output_y * (*out_width) + output_x) * 4;
+
+        output[output_idx] = 255;     // R
+        output[output_idx + 1] = 255; // G
+        output[output_idx + 2] = 255; // B
+        output[output_idx + 3] = 255; // A
+      }
+    }
+    else {
+      // Draw overlapping colored histograms for RGB
+      uint32_t bar_height_r = (hist_r[x] * hist_height) / max_count;
+      uint32_t bar_height_g =
+        (channels >= 3) ? (hist_g[x] * hist_height) / max_count : 0;
+      uint32_t bar_height_b =
+        (channels >= 3) ? (hist_b[x] * hist_height) / max_count : 0;
+
+      uint32_t max_bar_height = bar_height_r;
+      if (bar_height_g > max_bar_height) {
+        max_bar_height = bar_height_g;
+      }
+      if (bar_height_b > max_bar_height) {
+        max_bar_height = bar_height_b;
+      }
+
+      for (uint32_t y = 0; y < max_bar_height; y++) {
+        uint32_t output_x = x;
+        uint32_t output_y = hist_height - 1 - y;
+        uint32_t output_idx = (output_y * (*out_width) + output_x) * 4;
+
+        // Initialize to black
+        uint8_t r = 0, g = 0, b = 0;
+
+        // Add red channel contribution
+        if (y < bar_height_r) {
+          r = 255;
+        }
+
+        // Add green channel contribution
+        if (channels >= 3 && y < bar_height_g) {
+          g = 255;
+        }
+
+        // Add blue channel contribution
+        if (channels >= 3 && y < bar_height_b) {
+          b = 255;
+        }
+
+        output[output_idx] = r;       // R
+        output[output_idx + 1] = g;   // G
+        output[output_idx + 2] = b;   // B
+        output[output_idx + 3] = 255; // A
+      }
+    }
+  }
+
+  return output;
+}
+// File: src/parse_hex_color.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "parse_hex_color.h"
+#else
+#include "flatcv.h"
+#endif
+
+// Helper function to parse hex color code to RGB values
+void fcv_parse_hex_color(
+  const char *hex_color,
+  uint8_t *r,
+  uint8_t *g,
+  uint8_t *b
+) {
+  const char *hex = hex_color;
+
+  // Skip '#' if present
+  if (hex[0] == '#') {
+    hex++;
+  }
+
+  // Validate hex string length (should be 6 characters)
+  if (strlen(hex) != 6) {
+    // Default to white for invalid hex codes
+    *r = 255;
+    *g = 255;
+    *b = 255;
+    return;
+  }
+
+  // Validate that all characters are valid hex digits
+  for (int32_t i = 0; i < 6; i++) {
+    if (!((hex[i] >= '0' && hex[i] <= '9') ||
+          (hex[i] >= 'A' && hex[i] <= 'F') ||
+          (hex[i] >= 'a' && hex[i] <= 'f'))) {
+      // Default to white for invalid hex codes
+      *r = 255;
+      *g = 255;
+      *b = 255;
+      return;
+    }
+  }
+
+  // Parse hex values
+  uint32_t rgb_value;
+  sscanf(hex, "%x", &rgb_value);
+
+  *r = (rgb_value >> 16) & 0xFF;
+  *g = (rgb_value >> 8) & 0xFF;
+  *b = rgb_value & 0xFF;
+}
+// File: src/perspectivetransform.c
+#include <assert.h>
+#include <math.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "perspectivetransform.h"
+#else
+#include "flatcv.h"
+#endif
+
+// #define DEBUG_LOGGING
+
+#ifdef DEBUG_LOGGING
+#define log(msg) printf("DEBUG: %s\n", msg)
+#else
+#define log(msg) // No operation
+#endif
+
+/**
+ * Helper function to solve 8x8 linear system using Gaussian elimination
+ * Returns 1 on success, 0 on failure
+ */
+int32_t solve_linear_system(double A[8][8], double b[8], double x[8]) {
+  const int32_t n = 8;
+  const double epsilon = 1e-10;
+  int32_t i, j, k;
+
+  // Create augmented matrix [A|b] with extra safety margin
+  double aug[8][10]; // One extra column for safety
+
+  // Initialize augmented matrix
+  for (i = 0; i < n; i++) {
+    for (j = 0; j < n; j++) {
+      aug[i][j] = A[i][j];
+    }
+    aug[i][n] = b[i];
+  }
+
+  // Gaussian elimination with partial pivoting
+  for (i = 0; i < n; i++) {
+    // Find pivot
+    int32_t max_row = i;
+    double max_val = fabs(aug[i][i]);
+
+    for (k = i + 1; k < n; k++) {
+      if (fabs(aug[k][i]) > max_val) {
+        max_val = fabs(aug[k][i]);
+        max_row = k;
+      }
+    }
+
+    // Check for singularity
+    if (max_val < epsilon) {
+      log("Warning: Matrix is nearly singular\n");
+      return 0;
+    }
+
+    // Swap maximum row with current row
+    if (max_row != i) {
+      for (j = 0; j <= n; j++) {
+        double temp = aug[i][j];
+        aug[i][j] = aug[max_row][j];
+        aug[max_row][j] = temp;
+      }
+    }
+
+    // Eliminate column i
+    for (j = i + 1; j < n; j++) {
+      double factor = aug[j][i] / aug[i][i];
+      for (k = i; k <= n; k++) {
+        aug[j][k] -= factor * aug[i][k];
+      }
+    }
+  }
+
+  // Back substitution
+  for (i = n - 1; i >= 0; i--) {
+    if (fabs(aug[i][i]) < epsilon) {
+      log("Warning: Zero pivot encountered\n");
+      return 0;
+    }
+
+    x[i] = aug[i][n];
+    for (j = i + 1; j < n; j++) {
+      x[i] -= aug[i][j] * x[j];
+    }
+    x[i] /= aug[i][i];
+
+    // Check for invalid results
+    if (isnan(x[i]) || isinf(x[i])) {
+      log("Warning: Invalid result detected\n");
+      return 0;
+    }
+  }
+
+  return 1;
+}
+
+/**
+ * Calculate the perspective transformation matrix
+ * from the source and destination corner coordinates.
+ */
+Matrix3x3 *fcv_calculate_perspective_transform(
+  Corners *src_corners,
+  Corners *dst_corners
+) {
+  // Initialize matrices with zeros
+  double A[8][8] = {{0}};
+  double b[8] = {0};
+  double x[8] = {0};
+
+  // Identity matrix as fallback
+  static Matrix3x3 identity = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0};
+
+  if (!src_corners || !dst_corners) {
+    log("Error: NULL pointer passed to calculate_perspective_transform\n");
+    return &identity;
+  }
+
+#ifdef DEBUG_LOGGING
+  fprintf(stderr, "[C] Calculating perspective transform:\n");
+  fprintf(
+    stderr,
+    "src_corners:\ntl(%f, %f)\ntr(%f, %f)\nbr(%f, %f)\nbl(%f, %f)\n\n",
+    src_corners->tl_x,
+    src_corners->tl_y,
+    src_corners->tr_x,
+    src_corners->tr_y,
+    src_corners->br_x,
+    src_corners->br_y,
+    src_corners->bl_x,
+    src_corners->bl_y
+  );
+  fprintf(
+    stderr,
+    "dst_corners:\ntl(%f, %f)\ntr(%f, %f)\nbr(%f, %f)\nbl(%f, %f)\n\n",
+    dst_corners->tl_x,
+    dst_corners->tl_y,
+    dst_corners->tr_x,
+    dst_corners->tr_y,
+    dst_corners->br_x,
+    dst_corners->br_y,
+    dst_corners->bl_x,
+    dst_corners->bl_y
+  );
+#endif
+
+  // Validate input coordinates
+  if (isnan(src_corners->tl_x) || isnan(src_corners->tl_y) ||
+      isnan(src_corners->tr_x) || isnan(src_corners->tr_y) ||
+      isnan(src_corners->br_x) || isnan(src_corners->br_y) ||
+      isnan(src_corners->bl_x) || isnan(src_corners->bl_y) ||
+      isnan(dst_corners->tl_x) || isnan(dst_corners->tl_y) ||
+      isnan(dst_corners->tr_x) || isnan(dst_corners->tr_y) ||
+      isnan(dst_corners->br_x) || isnan(dst_corners->br_y) ||
+      isnan(dst_corners->bl_x) || isnan(dst_corners->bl_y)) {
+    log("Error: Invalid coordinates (NaN) detected\n");
+    return &identity;
+  }
+
+  // Set up the system of equations
+  for (int32_t i = 0; i < 4; i++) {
+    double srcX = 0.0, srcY = 0.0, dstX = 0.0, dstY = 0.0;
+
+    // Safely extract coordinates
+    switch (i) {
+    case 0: // Top-left
+      srcX = src_corners->tl_x;
+      srcY = src_corners->tl_y;
+      dstX = dst_corners->tl_x;
+      dstY = dst_corners->tl_y;
+      break;
+    case 1: // Top-right
+      srcX = src_corners->tr_x;
+      srcY = src_corners->tr_y;
+      dstX = dst_corners->tr_x;
+      dstY = dst_corners->tr_y;
+      break;
+    case 2: // Bottom-right
+      srcX = src_corners->br_x;
+      srcY = src_corners->br_y;
+      dstX = dst_corners->br_x;
+      dstY = dst_corners->br_y;
+      break;
+    case 3: // Bottom-left
+      srcX = src_corners->bl_x;
+      srcY = src_corners->bl_y;
+      dstX = dst_corners->bl_x;
+      dstY = dst_corners->bl_y;
+      break;
+    }
+
+    // Validate extracted coordinates
+    if (isinf(srcX) || isinf(srcY) || isinf(dstX) || isinf(dstY)) {
+      log("Error: Invalid coordinates (Inf) detected\n");
+      return &identity;
+    }
+
+    // First four equations for x coordinates
+    A[i][0] = srcX;
+    A[i][1] = srcY;
+    A[i][2] = 1.0;
+    A[i][6] = -srcX * dstX;
+    A[i][7] = -srcY * dstX;
+    b[i] = dstX;
+
+    // Last four equations for y coordinates
+    A[i + 4][3] = srcX;
+    A[i + 4][4] = srcY;
+    A[i + 4][5] = 1.0;
+    A[i + 4][6] = -srcX * dstY;
+    A[i + 4][7] = -srcY * dstY;
+    b[i + 4] = dstY;
+  }
+
+  log("Solve the system of equations …\n");
+  if (!solve_linear_system(A, b, x)) {
+    log("Failed to solve system, returning identity matrix\n");
+    return &identity;
+  }
+
+  // Validate solution
+  for (int32_t i = 0; i < 8; i++) {
+    if (isnan(x[i]) || isinf(x[i]) || fabs(x[i]) > 1e6) {
+      log("Error: Invalid solution values detected\n");
+      return &identity;
+    }
+  }
+
+  Matrix3x3 *result = malloc(sizeof(Matrix3x3));
+  *result = (Matrix3x3){x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1.0};
+
+#ifdef DEBUG_LOGGING
+  fprintf(stderr, "Result matrix:\n");
+  fprintf(stderr, "%f, %f, %f\n", result->m00, result->m01, result->m02);
+  fprintf(stderr, "%f, %f, %f\n", result->m10, result->m11, result->m12);
+  fprintf(stderr, "%f, %f, %f\n", result->m20, result->m21, result->m22);
+#endif
+
+  // Final validation of the result matrix
+  if (isnan(result->m00) || isnan(result->m01) || isnan(result->m02) ||
+      isnan(result->m10) || isnan(result->m11) || isnan(result->m12) ||
+      isnan(result->m20) || isnan(result->m21) || isnan(result->m22) ||
+      isinf(result->m00) || isinf(result->m01) || isinf(result->m02) ||
+      isinf(result->m10) || isinf(result->m11) || isinf(result->m12) ||
+      isinf(result->m20) || isinf(result->m21) || isinf(result->m22)) {
+    log("Error: Invalid values in result matrix\n");
+    return &identity;
+  }
+
+  return result;
+}
+
+/**
+ * Apply the transformation matrix to the input image
+ * and store the result in the output image.
+ * Use bilinear interpolation to calculate final pixel values.
+ */
+uint8_t *fcv_apply_matrix_3x3(
+  int32_t in_width,
+  int32_t in_height,
+  uint8_t *in_data,
+  int32_t out_width,
+  int32_t out_height,
+  Matrix3x3 *tmat
+) {
+  if (!in_data || !tmat) {
+    return NULL;
+  }
+
+  if (in_width <= 0 || in_height <= 0 || out_width <= 0 || out_height <= 0) {
+    return NULL;
+  }
+
+  // Check for overflow: out_width * out_height * 4 (dimensions already
+  // validated > 0)
+  if ((size_t)out_width > SIZE_MAX / (size_t)out_height) {
+    return NULL;
+  }
+  size_t out_pixels = (size_t)out_width * (size_t)out_height;
+  if (out_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+
+  // Patch flip matrix if needed
+  if (fabs(tmat->m00 + 1.0) < 1e-9 && fabs(tmat->m11 + 1.0) < 1e-9 &&
+      tmat->m02 == 0.0 && tmat->m12 == 0.0) {
+    tmat->m02 = in_width - 1;
+    tmat->m12 = in_height - 1;
+  }
+
+  uint8_t *out_data = calloc(out_pixels * 4, sizeof(uint8_t));
+
+  if (!out_data) { // Memory allocation failed
+    return NULL;
+  }
+
+  // Iterate through every pixel in the output image
+  for (int32_t out_y = 0; out_y < out_height; ++out_y) {
+    for (int32_t out_x = 0; out_x < out_width; ++out_x) {
+      // Apply the inverse transformation to find the corresponding source pixel
+      double w = tmat->m20 * out_x + tmat->m21 * out_y + tmat->m22;
+      if (fabs(w) < 1e-10) {
+        continue; // Skip if w is too close to zero
+      }
+
+      double srcX = (tmat->m00 * out_x + tmat->m01 * out_y + tmat->m02) / w;
+      double srcY = (tmat->m10 * out_x + tmat->m11 * out_y + tmat->m12) / w;
+
+      // Convert source coordinates to integers
+      int32_t x0 = (int32_t)floor(srcX);
+      int32_t y0 = (int32_t)floor(srcY);
+      int32_t x1 = x0 + 1;
+      int32_t y1 = y0 + 1;
+
+      // Verify that the anchor pixel is inside the source image
+      if (x0 >= 0 && x0 < in_width && y0 >= 0 && y0 < in_height) {
+
+        // Clamp the neighbor coordinates so that a (degenerated)
+        // bilinear interpolation can be applied at the image borders.
+        int32_t x1c = (x1 < in_width) ? x1 : x0;
+        int32_t y1c = (y1 < in_height) ? y1 : y0;
+
+        double dx = srcX - x0;
+        double dy = srcY - y0;
+
+        // If a neighbour got clamped we force the corresponding weight to 0
+        if (x1c == x0) {
+          dx = 0.0;
+        }
+        if (y1c == y0) {
+          dy = 0.0;
+        }
+
+        uint8_t *p00 = &in_data[(y0 * in_width + x0) * 4];
+        uint8_t *p01 = &in_data[(y0 * in_width + x1c) * 4];
+        uint8_t *p10 = &in_data[(y1c * in_width + x0) * 4];
+        uint8_t *p11 = &in_data[(y1c * in_width + x1c) * 4];
+
+        for (int32_t c = 0; c < 4; ++c) {
+          out_data[(out_y * out_width + out_x) * 4 + c] =
+            (uint8_t)(p00[c] * (1 - dx) * (1 - dy) + p01[c] * dx * (1 - dy) +
+                      p10[c] * (1 - dx) * dy + p11[c] * dx * dy);
+        }
+      }
+    }
+  }
+
+  return out_data;
+}
+// File: src/qr_code.c
+#include <math.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "qr_code.h"
+#else
+#include "flatcv.h"
+#endif
+
+#define MAX_QR_CODES 10
+#define MAX_CANDIDATES 200
+#define MAX_TRIPLES 10
+/* Max alignment patterns per QR. Version 40 has 7×7 = 49 grid positions,
+   of which 3 (top-left, top-right, bottom-left) overlap the finder patterns
+   and are skipped — leaving at most 46. */
+#define QR_MAX_ALIGNMENTS 46
+
+/* ---- Types ---- */
+
+typedef struct {
+  float x, y, module_size;
+} FinderPattern;
+
+/* ---- Adaptive binarization ----
+   Hybrid thresholding: combines a global Otsu threshold with a local-variance
+   check via a mean/square integral image. In high-contrast windows we
+   threshold against the local mean (handles uneven illumination on e-ink
+   displays and perspective-lit photos); in low-contrast windows we fall back
+   to the global Otsu threshold (preserves large uniform dark/light regions
+   that a pure local mean would invert).
+   Output buffer: 0 for dark, 255 for light. */
+static int compute_otsu_threshold(uint8_t const *gray, int w, int h) {
+  size_t n = (size_t)w * (size_t)h;
+  uint32_t hist[256] = {0};
+  for (size_t i = 0; i < n; i++) {
+    hist[gray[i]]++;
+  }
+  double total = (double)n;
+  double sum_all = 0.0;
+  for (int i = 0; i < 256; i++) {
+    sum_all += (double)i * hist[i];
+  }
+  double w_bg = 0.0, sum_bg = 0.0;
+  double max_var = 0.0;
+  int best_t = 128;
+  for (int t = 0; t < 256; t++) {
+    w_bg += hist[t];
+    if (w_bg == 0.0 || w_bg == total) {
+      continue;
+    }
+    sum_bg += (double)t * hist[t];
+    double w_fg = total - w_bg;
+    double mean_bg = sum_bg / w_bg;
+    double mean_fg = (sum_all - sum_bg) / w_fg;
+    double diff = mean_bg - mean_fg;
+    double var_between = w_bg * w_fg * diff * diff;
+    if (var_between > max_var) {
+      max_var = var_between;
+      best_t = t;
+    }
+  }
+  return best_t;
+}
+
+static uint8_t *binarize_global_otsu(uint8_t const *gray, int w, int h) {
+  if (w <= 0 || h <= 0) {
+    return NULL;
+  }
+  size_t n = (size_t)w * (size_t)h;
+  uint8_t *bin = malloc(n);
+  if (!bin) {
+    return NULL;
+  }
+  int t = compute_otsu_threshold(gray, w, h);
+  for (size_t i = 0; i < n; i++) {
+    bin[i] = (gray[i] < t) ? 0 : 255;
+  }
+  return bin;
+}
+
+static uint8_t *binarize_adaptive(uint8_t const *gray, int w, int h) {
+  if (w <= 0 || h <= 0) {
+    return NULL;
+  }
+  size_t n = (size_t)w * (size_t)h;
+  uint8_t *bin = malloc(n);
+  if (!bin) {
+    return NULL;
+  }
+  size_t iw = (size_t)w + 1;
+  uint64_t *ii_sum = calloc(iw * (size_t)(h + 1), sizeof(uint64_t));
+  uint64_t *ii_sq = calloc(iw * (size_t)(h + 1), sizeof(uint64_t));
+  if (!ii_sum || !ii_sq) {
+    free(bin);
+    free(ii_sum);
+    free(ii_sq);
+    return NULL;
+  }
+  for (int y = 0; y < h; y++) {
+    uint64_t row_sum = 0;
+    uint64_t row_sq = 0;
+    for (int x = 0; x < w; x++) {
+      uint8_t v = gray[y * w + x];
+      row_sum += v;
+      row_sq += (uint64_t)v * v;
+      size_t idx = (size_t)(y + 1) * iw + (size_t)(x + 1);
+      ii_sum[idx] = ii_sum[idx - iw] + row_sum;
+      ii_sq[idx] = ii_sq[idx - iw] + row_sq;
+    }
+  }
+  int global_t = compute_otsu_threshold(gray, w, h);
+  int block = (w < h ? w : h) / 8;
+  if (block < 15) {
+    block = 15;
+  }
+  if (block > 101) {
+    block = 101;
+  }
+  if ((block & 1) == 0) {
+    block++;
+  }
+  int half = block / 2;
+  /* Below min_std the window is treated as uniform and the global threshold
+     decides the pixel class. This keeps large uniform dark regions (e.g.
+     finder-pattern centers, dark backgrounds) from being inverted by a local
+     mean that sits right on the pixel value. */
+  const double min_std = 24.0;
+  for (int y = 0; y < h; y++) {
+    int y0 = y - half;
+    if (y0 < 0) {
+      y0 = 0;
+    }
+    int y1 = y + half;
+    if (y1 >= h) {
+      y1 = h - 1;
+    }
+    for (int x = 0; x < w; x++) {
+      int x0 = x - half;
+      if (x0 < 0) {
+        x0 = 0;
+      }
+      int x1 = x + half;
+      if (x1 >= w) {
+        x1 = w - 1;
+      }
+      double count = (double)((y1 - y0 + 1) * (x1 - x0 + 1));
+      size_t a = (size_t)(y1 + 1) * iw + (size_t)(x1 + 1);
+      size_t b = (size_t)y0 * iw + (size_t)(x1 + 1);
+      size_t c = (size_t)(y1 + 1) * iw + (size_t)x0;
+      size_t d = (size_t)y0 * iw + (size_t)x0;
+      double sum = (double)(ii_sum[a] - ii_sum[b] - ii_sum[c] + ii_sum[d]);
+      double sqsum = (double)(ii_sq[a] - ii_sq[b] - ii_sq[c] + ii_sq[d]);
+      double mean = sum / count;
+      double var = sqsum / count - mean * mean;
+      if (var < 0.0) {
+        var = 0.0;
+      }
+      double std = sqrt(var);
+      uint8_t gv = gray[y * w + x];
+      int dark;
+      if (std < min_std) {
+        dark = (gv < global_t);
+      }
+      else {
+        dark = ((double)gv < mean);
+      }
+      bin[y * w + x] = dark ? 0 : 255;
+    }
+  }
+  free(ii_sum);
+  free(ii_sq);
+  return bin;
+}
+
+/* ---- Sauvola binarization ----
+   T = mean * (1 + k * (std / R - 1)), k=0.34, R=128. Scales the threshold
+   by local std, which catches faded/low-contrast modules that the fixed
+   min_std fallback in binarize_adaptive misclassifies. Block deliberately
+   smaller than binarize_adaptive's so it stays responsive when modules are
+   only 2-3 px wide. */
+static uint8_t *binarize_sauvola(uint8_t const *gray, int w, int h) {
+  if (w <= 0 || h <= 0) {
+    return NULL;
+  }
+  size_t n = (size_t)w * (size_t)h;
+  uint8_t *bin = malloc(n);
+  if (!bin) {
+    return NULL;
+  }
+  size_t iw = (size_t)w + 1;
+  uint64_t *ii_sum = calloc(iw * (size_t)(h + 1), sizeof(uint64_t));
+  uint64_t *ii_sq = calloc(iw * (size_t)(h + 1), sizeof(uint64_t));
+  if (!ii_sum || !ii_sq) {
+    free(bin);
+    free(ii_sum);
+    free(ii_sq);
+    return NULL;
+  }
+  for (int y = 0; y < h; y++) {
+    uint64_t row_sum = 0;
+    uint64_t row_sq = 0;
+    for (int x = 0; x < w; x++) {
+      uint8_t v = gray[y * w + x];
+      row_sum += v;
+      row_sq += (uint64_t)v * v;
+      size_t idx = (size_t)(y + 1) * iw + (size_t)(x + 1);
+      ii_sum[idx] = ii_sum[idx - iw] + row_sum;
+      ii_sq[idx] = ii_sq[idx - iw] + row_sq;
+    }
+  }
+  int block = (w < h ? w : h) / 12;
+  if (block < 11) {
+    block = 11;
+  }
+  if (block > 81) {
+    block = 81;
+  }
+  if ((block & 1) == 0) {
+    block++;
+  }
+  int half = block / 2;
+  const double k = 0.34;
+  const double R = 128.0;
+  for (int y = 0; y < h; y++) {
+    int y0 = y - half;
+    if (y0 < 0) {
+      y0 = 0;
+    }
+    int y1 = y + half;
+    if (y1 >= h) {
+      y1 = h - 1;
+    }
+    for (int x = 0; x < w; x++) {
+      int x0 = x - half;
+      if (x0 < 0) {
+        x0 = 0;
+      }
+      int x1 = x + half;
+      if (x1 >= w) {
+        x1 = w - 1;
+      }
+      double count = (double)((y1 - y0 + 1) * (x1 - x0 + 1));
+      size_t a = (size_t)(y1 + 1) * iw + (size_t)(x1 + 1);
+      size_t b = (size_t)y0 * iw + (size_t)(x1 + 1);
+      size_t c = (size_t)(y1 + 1) * iw + (size_t)x0;
+      size_t d = (size_t)y0 * iw + (size_t)x0;
+      double sum = (double)(ii_sum[a] - ii_sum[b] - ii_sum[c] + ii_sum[d]);
+      double sqsum = (double)(ii_sq[a] - ii_sq[b] - ii_sq[c] + ii_sq[d]);
+      double mean = sum / count;
+      double var = sqsum / count - mean * mean;
+      if (var < 0.0) {
+        var = 0.0;
+      }
+      double std = sqrt(var);
+      double thresh = mean * (1.0 + k * (std / R - 1.0));
+      uint8_t gv = gray[y * w + x];
+      bin[y * w + x] = ((double)gv < thresh) ? 0 : 255;
+    }
+  }
+  free(ii_sum);
+  free(ii_sq);
+  return bin;
+}
+
+/* ---- Hybrid binarization (zxing-cpp port) ----
+   Port of zxing-cpp's HybridBinarizer (core/src/HybridBinarizer.cpp, NEW
+   algorithm). Works in 8x8 blocks and computes a per-block threshold of
+   (min+max)/2 only when the dynamic range exceeds 24; low-contrast blocks
+   are marked "unknown" and inherit their threshold from a 5x5 smoothing
+   over the *known* neighbours, followed by raster flood-fill of any
+   surviving zeros. This is substantially more robust than binarize_adaptive
+   on low-contrast pastel palettes: flatcv's adaptive falls back to the
+   global Otsu threshold whenever local std<24, which misclassifies entire
+   interior regions; zxing's scheme instead propagates an edge-of-QR
+   threshold inward.
+   Output: 0 = dark, 255 = light. Returns NULL if image smaller than the
+   5x5 window (40 px) — caller should fall back to global Otsu. */
+#define HB_BLOCK_SIZE 8
+#define HB_WINDOW_BLOCKS 5
+#define HB_WINDOW_SIZE (HB_BLOCK_SIZE * HB_WINDOW_BLOCKS)
+#define HB_MIN_DYNAMIC_RANGE 24
+
+static uint8_t *binarize_hybrid(uint8_t const *gray, int w, int h) {
+  if (w < HB_WINDOW_SIZE || h < HB_WINDOW_SIZE) {
+    return NULL;
+  }
+  int sub_w = (w + HB_BLOCK_SIZE - 1) / HB_BLOCK_SIZE;
+  int sub_h = (h + HB_BLOCK_SIZE - 1) / HB_BLOCK_SIZE;
+  size_t sub_n = (size_t)sub_w * (size_t)sub_h;
+  uint8_t *raw = malloc(sub_n);
+  uint8_t *smoothed = malloc(sub_n);
+  uint8_t *bin = malloc((size_t)w * (size_t)h);
+  if (!raw || !smoothed || !bin) {
+    free(raw);
+    free(smoothed);
+    free(bin);
+    return NULL;
+  }
+
+  /* Per-block threshold: (max+min)/2 if range exceeds min_dynamic_range,
+     else 0 (unknown — filled in by smoothing). Last row/col of blocks is
+     clamped so the 8x8 window stays fully inside the image. */
+  for (int by = 0; by < sub_h; by++) {
+    int y0 = by * HB_BLOCK_SIZE;
+    if (y0 > h - HB_BLOCK_SIZE) {
+      y0 = h - HB_BLOCK_SIZE;
+    }
+    for (int bx = 0; bx < sub_w; bx++) {
+      int x0 = bx * HB_BLOCK_SIZE;
+      if (x0 > w - HB_BLOCK_SIZE) {
+        x0 = w - HB_BLOCK_SIZE;
+      }
+      uint8_t mn = 255, mx = 0;
+      for (int yy = 0; yy < HB_BLOCK_SIZE; yy++) {
+        uint8_t const *row = gray + (size_t)(y0 + yy) * (size_t)w + x0;
+        for (int xx = 0; xx < HB_BLOCK_SIZE; xx++) {
+          uint8_t v = row[xx];
+          if (v < mn) {
+            mn = v;
+          }
+          if (v > mx) {
+            mx = v;
+          }
+        }
+      }
+      raw[(size_t)by * sub_w + bx] =
+        (mx - mn > HB_MIN_DYNAMIC_RANGE) ? (uint8_t)(((int)mx + mn) / 2) : 0;
+    }
+  }
+
+  /* 5x5 gaussian-like smoothing over non-zero thresholds (center counted
+     twice). Low-contrast blocks with no contrasty neighbour remain 0. */
+  const int R = HB_WINDOW_BLOCKS / 2;
+  for (int by = 0; by < sub_h; by++) {
+    for (int bx = 0; bx < sub_w; bx++) {
+      int left = bx;
+      if (left < R) {
+        left = R;
+      }
+      else if (left > sub_w - R - 1) {
+        left = sub_w - R - 1;
+      }
+      int top = by;
+      if (top < R) {
+        top = R;
+      }
+      else if (top > sub_h - R - 1) {
+        top = sub_h - R - 1;
+      }
+      int center = raw[(size_t)by * sub_w + bx];
+      int sum = center * 2;
+      int n = (center > 0) ? 2 : 0;
+      for (int dy = -R; dy <= R; dy++) {
+        for (int dx = -R; dx <= R; dx++) {
+          int t = raw[(size_t)(top + dy) * sub_w + (left + dx)];
+          sum += t;
+          if (t > 0) {
+            n++;
+          }
+        }
+      }
+      smoothed[(size_t)by * sub_w + bx] = (n > 0) ? (uint8_t)(sum / n) : 0;
+    }
+  }
+  free(raw);
+
+  /* Raster flood-fill of any remaining zero-threshold blocks. Propagates
+     the nearest-preceding non-zero threshold forward; runs of trailing
+     zeros inherit from the last non-zero value seen. */
+  int last = -1;
+  for (size_t i = 0; i < sub_n; i++) {
+    if (smoothed[i]) {
+      if ((int)i != last + 1) {
+        uint8_t fill = smoothed[i];
+        for (int j = last + 1; j < (int)i; j++) {
+          smoothed[j] = fill;
+        }
+      }
+      last = (int)i;
+    }
+  }
+  if (last < (int)sub_n - 1) {
+    uint8_t fill = (last >= 0) ? smoothed[last] : 128;
+    for (int j = last + 1; j < (int)sub_n; j++) {
+      smoothed[j] = fill;
+    }
+  }
+
+  /* Apply per-block threshold to every pixel. Pixels in the rightmost/
+     bottom partial block simply use that block's threshold — block-index
+     lookup via integer division is fine even when (w % BLOCK_SIZE) != 0
+     because sub_w/sub_h were computed with ceil(). */
+  for (int y = 0; y < h; y++) {
+    int by = y / HB_BLOCK_SIZE;
+    if (by >= sub_h) {
+      by = sub_h - 1;
+    }
+    uint8_t const *row = gray + (size_t)y * w;
+    uint8_t *out = bin + (size_t)y * w;
+    for (int x = 0; x < w; x++) {
+      int bx = x / HB_BLOCK_SIZE;
+      if (bx >= sub_w) {
+        bx = sub_w - 1;
+      }
+      uint8_t t = smoothed[(size_t)by * sub_w + bx];
+      out[x] = (row[x] <= t) ? 0 : 255;
+    }
+  }
+  free(smoothed);
+  return bin;
+}
+
+/* ---- Unsharp mask on grayscale ----
+   3-tap separable Gaussian blur, then sharp = clamp(2*g - blurred). The
+   small kernel matches level_4/5 blur radii (0.8-1.8 px). Returns a freshly
+   allocated grayscale buffer. */
+static uint8_t *unsharp_mask_gray(uint8_t const *gray, int w, int h) {
+  if (w <= 2 || h <= 2) {
+    return NULL;
+  }
+  size_t n = (size_t)w * (size_t)h;
+  uint8_t *blurred = malloc(n);
+  uint8_t *tmp = malloc(n);
+  uint8_t *sharp = malloc(n);
+  if (!blurred || !tmp || !sharp) {
+    free(blurred);
+    free(tmp);
+    free(sharp);
+    return NULL;
+  }
+  /* Horizontal pass [1 2 1] / 4 into tmp. */
+  for (int y = 0; y < h; y++) {
+    uint8_t const *row = gray + (size_t)y * w;
+    uint8_t *out_row = tmp + (size_t)y * w;
+    out_row[0] = row[0];
+    out_row[w - 1] = row[w - 1];
+    for (int x = 1; x < w - 1; x++) {
+      int v = row[x - 1] + 2 * row[x] + row[x + 1];
+      out_row[x] = (uint8_t)(v >> 2);
+    }
+  }
+  /* Vertical pass [1 2 1] / 4 from tmp into blurred. */
+  for (int x = 0; x < w; x++) {
+    blurred[x] = tmp[x];
+    blurred[(size_t)(h - 1) * w + x] = tmp[(size_t)(h - 1) * w + x];
+  }
+  for (int y = 1; y < h - 1; y++) {
+    for (int x = 0; x < w; x++) {
+      int v = tmp[(size_t)(y - 1) * w + x] + 2 * tmp[(size_t)y * w + x] +
+              tmp[(size_t)(y + 1) * w + x];
+      blurred[(size_t)y * w + x] = (uint8_t)(v >> 2);
+    }
+  }
+  /* sharp = clamp(2*gray - blurred). */
+  for (size_t i = 0; i < n; i++) {
+    int v = 2 * (int)gray[i] - (int)blurred[i];
+    if (v < 0) {
+      v = 0;
+    }
+    if (v > 255) {
+      v = 255;
+    }
+    sharp[i] = (uint8_t)v;
+  }
+  free(blurred);
+  free(tmp);
+  return sharp;
+}
+
+/* ---- N-x bilinear upsample on grayscale ----
+   Returns a freshly allocated (N*w x N*h) buffer. Larger N gives bigger
+   effective module sizes (helping finder ratio checks and subpixel
+   refinement) at quadratic memory + time cost. */
+static uint8_t *upsample_nx_bilinear(uint8_t const *gray, int w, int h, int n) {
+  if (w <= 0 || h <= 0 || n < 2) {
+    return NULL;
+  }
+  int w2 = w * n;
+  int h2 = h * n;
+  double inv = 1.0 / (double)n;
+  uint8_t *out = malloc((size_t)w2 * (size_t)h2);
+  if (!out) {
+    return NULL;
+  }
+  for (int y = 0; y < h2; y++) {
+    double sy = ((double)y + 0.5) * inv - 0.5;
+    int y0 = (int)floor(sy);
+    int y1 = y0 + 1;
+    double fy = sy - y0;
+    if (y0 < 0) {
+      y0 = 0;
+    }
+    if (y1 < 0) {
+      y1 = 0;
+    }
+    if (y0 > h - 1) {
+      y0 = h - 1;
+    }
+    if (y1 > h - 1) {
+      y1 = h - 1;
+    }
+    for (int x = 0; x < w2; x++) {
+      double sx = ((double)x + 0.5) * inv - 0.5;
+      int x0 = (int)floor(sx);
+      int x1 = x0 + 1;
+      double fx = sx - x0;
+      if (x0 < 0) {
+        x0 = 0;
+      }
+      if (x1 < 0) {
+        x1 = 0;
+      }
+      if (x0 > w - 1) {
+        x0 = w - 1;
+      }
+      if (x1 > w - 1) {
+        x1 = w - 1;
+      }
+      double v00 = gray[y0 * w + x0];
+      double v01 = gray[y0 * w + x1];
+      double v10 = gray[y1 * w + x0];
+      double v11 = gray[y1 * w + x1];
+      double v0 = v00 * (1.0 - fx) + v01 * fx;
+      double v1 = v10 * (1.0 - fx) + v11 * fx;
+      double v = v0 * (1.0 - fy) + v1 * fy;
+      if (v < 0.0) {
+        v = 0.0;
+      }
+      if (v > 255.0) {
+        v = 255.0;
+      }
+      out[y * w2 + x] = (uint8_t)(v + 0.5);
+    }
+  }
+  return out;
+}
+
+/* ---- 2x grayscale downsample (box average) ----
+   Each output pixel is the mean of a 2x2 input block. Mirrors zxing-cpp's
+   LumImagePyramid step. Anti-aliased QR-edge pixels get averaged with
+   interior pixels, producing new intermediate luma values within an 8x8
+   block that raise its dynamic range above the hybrid binariser's
+   MIN_DYNAMIC_RANGE=24 threshold. Essential for pastel palettes where
+   module-vs-background contrast at full resolution is below that gate
+   everywhere in the image. */
+static uint8_t *downsample_2x_box(uint8_t const *gray, int w, int h) {
+  if (w < 2 || h < 2) {
+    return NULL;
+  }
+  int nw = w / 2;
+  int nh = h / 2;
+  uint8_t *out = malloc((size_t)nw * (size_t)nh);
+  if (!out) {
+    return NULL;
+  }
+  for (int y = 0; y < nh; y++) {
+    int sy = y * 2;
+    uint8_t const *r0 = gray + (size_t)sy * w;
+    uint8_t const *r1 = gray + (size_t)(sy + 1) * w;
+    uint8_t *dst = out + (size_t)y * nw;
+    for (int x = 0; x < nw; x++) {
+      int sx = x * 2;
+      int sum = r0[sx] + r0[sx + 1] + r1[sx] + r1[sx + 1];
+      dst[x] = (uint8_t)(sum >> 2);
+    }
+  }
+  return out;
+}
+
+/* ---- 3x3 grayscale median filter ----
+   Removes salt-and-pepper noise without softening edges (unlike Gaussian
+   blur). Targets level_5's heavy random noise (sigma 4-9) that injects
+   isolated black/white pixels into otherwise-uniform module interiors. */
+static uint8_t *median_filter_3x3(uint8_t const *gray, int w, int h) {
+  if (w <= 2 || h <= 2) {
+    return NULL;
+  }
+  uint8_t *out = malloc((size_t)w * h);
+  if (!out) {
+    return NULL;
+  }
+  /* Copy the 1-pixel border unchanged. */
+  for (int x = 0; x < w; x++) {
+    out[x] = gray[x];
+    out[(size_t)(h - 1) * w + x] = gray[(size_t)(h - 1) * w + x];
+  }
+  for (int y = 1; y < h - 1; y++) {
+    out[y * w] = gray[y * w];
+    out[y * w + (w - 1)] = gray[y * w + (w - 1)];
+  }
+  for (int y = 1; y < h - 1; y++) {
+    for (int x = 1; x < w - 1; x++) {
+      uint8_t v[9];
+      v[0] = gray[(y - 1) * w + (x - 1)];
+      v[1] = gray[(y - 1) * w + x];
+      v[2] = gray[(y - 1) * w + (x + 1)];
+      v[3] = gray[y * w + (x - 1)];
+      v[4] = gray[y * w + x];
+      v[5] = gray[y * w + (x + 1)];
+      v[6] = gray[(y + 1) * w + (x - 1)];
+      v[7] = gray[(y + 1) * w + x];
+      v[8] = gray[(y + 1) * w + (x + 1)];
+      /* Insertion sort to find the median (5th element of 9). */
+      for (int i = 1; i < 9; i++) {
+        uint8_t tmp = v[i];
+        int j = i - 1;
+        while (j >= 0 && v[j] > tmp) {
+          v[j + 1] = v[j];
+          j--;
+        }
+        v[j + 1] = tmp;
+      }
+      out[y * w + x] = v[4];
+    }
+  }
+  return out;
+}
+
+/* ---- Inverted polarity ----
+   QR spec allows light-on-dark modules (level_13 corpus); flatcv's finder
+   and sampling code assume dark-on-light. Retrying with 255 - v lets the
+   same pipeline decode both polarities without any per-stage branching. */
+static uint8_t *invert_gray(uint8_t const *gray, int w, int h) {
+  if (w <= 0 || h <= 0) {
+    return NULL;
+  }
+  size_t n = (size_t)w * (size_t)h;
+  uint8_t *out = malloc(n);
+  if (!out) {
+    return NULL;
+  }
+  for (size_t i = 0; i < n; i++) {
+    out[i] = (uint8_t)(255 - gray[i]);
+  }
+  return out;
+}
+
+/* ---- Pixel access ---- */
+
+static int is_dark(uint8_t const *px, int w, int h, int x, int y) {
+  if (x < 0 || x >= w || y < 0 || y >= h) {
+    return 0;
+  }
+  return px[y * w + x] < 128;
+}
+
+/* ---- Finder pattern ratio check (1:1:3:1:1) ---- */
+
+static int check_ratio(const int s[5]) {
+  int total = 0;
+  for (int i = 0; i < 5; i++) {
+    if (s[i] == 0) {
+      return 0;
+    }
+    total += s[i];
+  }
+  if (total < 7) {
+    return 0;
+  }
+  float m = (float)total / 7.0f;
+  float v = m * 0.7f;
+  return (
+    fabsf(s[0] - m) <= v && fabsf(s[1] - m) <= v &&
+    fabsf(s[2] - 3 * m) <= 3 * v && fabsf(s[3] - m) <= v && fabsf(s[4] - m) <= v
+  );
+}
+
+/* ---- Cross-check along an arbitrary angle ----
+   Walk along direction (cos_a, sin_a) through (cx, cy), counting the
+   1:1:3:1:1 dark-light-dark-light-dark run pattern.
+   Returns module_size if valid, -1 otherwise. Writes refined center. */
+static float cross_check_angle(
+  uint8_t const *px,
+  int w,
+  int h,
+  float cx,
+  float cy,
+  float cos_a,
+  float sin_a,
+  int exp_total,
+  float *out_cx,
+  float *out_cy
+) {
+  int max_walk = (w > h ? w : h);
+
+  /* Walk negative: expect dark(center), light, dark at boundary */
+  int neg_dark_center = 0;
+  int neg_light = 0;
+  int neg_dark_outer = 0;
+  int phase = 0;
+
+  for (int step = 1; step <= max_walk && phase < 3; step++) {
+    float fx = cx - step * cos_a;
+    float fy = cy - step * sin_a;
+    int ix = (int)(fx + 0.5f), iy = (int)(fy + 0.5f);
+    if (ix < 0 || ix >= w || iy < 0 || iy >= h) {
+      break;
+    }
+    int dark = is_dark(px, w, h, ix, iy);
+    if (phase == 0) {
+      if (dark) {
+        neg_dark_center++;
+      }
+      else {
+        neg_light = 1;
+        phase = 1;
+      }
+    }
+    else if (phase == 1) {
+      if (!dark) {
+        neg_light++;
+      }
+      else {
+        neg_dark_outer = 1;
+        phase = 2;
+      }
+    }
+    else {
+      if (dark) {
+        neg_dark_outer++;
+      }
+      else {
+        phase = 3;
+      }
+    }
+  }
+
+  /* Walk positive: expect dark(center), light, dark at boundary */
+  int pos_dark_center = 0;
+  int pos_light = 0;
+  int pos_dark_outer = 0;
+  phase = 0;
+
+  for (int step = 1; step <= max_walk && phase < 3; step++) {
+    float fx = cx + step * cos_a;
+    float fy = cy + step * sin_a;
+    int ix = (int)(fx + 0.5f), iy = (int)(fy + 0.5f);
+    if (ix < 0 || ix >= w || iy < 0 || iy >= h) {
+      break;
+    }
+    int dark = is_dark(px, w, h, ix, iy);
+    if (phase == 0) {
+      if (dark) {
+        pos_dark_center++;
+      }
+      else {
+        pos_light = 1;
+        phase = 1;
+      }
+    }
+    else if (phase == 1) {
+      if (!dark) {
+        pos_light++;
+      }
+      else {
+        pos_dark_outer = 1;
+        phase = 2;
+      }
+    }
+    else {
+      if (dark) {
+        pos_dark_outer++;
+      }
+      else {
+        phase = 3;
+      }
+    }
+  }
+
+  int s[5];
+  s[0] = neg_dark_outer;
+  s[1] = neg_light;
+  s[2] = neg_dark_center + 1 + pos_dark_center;
+  s[3] = pos_light;
+  s[4] = pos_dark_outer;
+
+  int total = s[0] + s[1] + s[2] + s[3] + s[4];
+  if (exp_total > 0 && abs(total - exp_total) > exp_total) {
+    return -1;
+  }
+  if (!check_ratio(s)) {
+    return -1;
+  }
+
+  float offset = (float)(pos_dark_center - neg_dark_center) / 2.0f;
+  *out_cx = cx + offset * cos_a;
+  *out_cy = cy + offset * sin_a;
+
+  return (float)total / 7.0f;
+}
+
+/* ---- Radial cross-check at multiple angles ---- */
+
+static int cross_check_radial(
+  uint8_t const *px,
+  int w,
+  int h,
+  float cx,
+  float cy,
+  float exp_ms,
+  float *out_cx,
+  float *out_cy,
+  float *out_ms
+) {
+  /* 8 angles: 0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5 degrees */
+  static const float angles[8] = {
+    0.0f,
+    0.3927f,
+    0.7854f,
+    1.1781f,
+    1.5708f,
+    1.9635f,
+    2.3562f,
+    2.7489f,
+  };
+  int exp_total = (int)(exp_ms * 7.0f + 0.5f);
+  float rcx_arr[8], rcy_arr[8], rms_arr[8];
+  int passed[8];
+  int npass = 0;
+
+  for (int a = 0; a < 8; a++) {
+    float ca = cosf(angles[a]), sa = sinf(angles[a]);
+    rms_arr[a] = cross_check_angle(
+      px,
+      w,
+      h,
+      cx,
+      cy,
+      ca,
+      sa,
+      exp_total,
+      &rcx_arr[a],
+      &rcy_arr[a]
+    );
+    passed[a] = (rms_arr[a] > 0);
+    if (passed[a]) {
+      npass++;
+    }
+  }
+
+  if (npass < 4) {
+    return 0;
+  }
+
+  int best_pair = -1;
+  float best_pair_ms_match = 1e9f;
+  for (int a = 0; a < 4; a++) {
+    int b = a + 4;
+    if (passed[a] && passed[b]) {
+      float ms_err = fabsf(rms_arr[a] - exp_ms) + fabsf(rms_arr[b] - exp_ms);
+      if (best_pair < 0 || ms_err < best_pair_ms_match) {
+        best_pair = a;
+        best_pair_ms_match = ms_err;
+      }
+    }
+  }
+
+  if (best_pair >= 0) {
+    int a = best_pair, b = best_pair + 4;
+    float ca = cosf(angles[a]), sa = sinf(angles[a]);
+    float off_a = (rcx_arr[a] - cx) * ca + (rcy_arr[a] - cy) * sa;
+    float cb = cosf(angles[b]), sb = sinf(angles[b]);
+    float off_b = (rcx_arr[b] - cx) * cb + (rcy_arr[b] - cy) * sb;
+    *out_cx = cx + off_a * ca + off_b * cb;
+    *out_cy = cy + off_a * sa + off_b * sb;
+    *out_ms = (rms_arr[a] + rms_arr[b]) / 2.0f;
+  }
+  else {
+    float sum_cx = 0, sum_cy = 0, sum_ms = 0;
+    for (int a = 0; a < 8; a++) {
+      if (passed[a]) {
+        sum_cx += rcx_arr[a];
+        sum_cy += rcy_arr[a];
+        sum_ms += rms_arr[a];
+      }
+    }
+    *out_cx = sum_cx / npass;
+    *out_cy = sum_cy / npass;
+    *out_ms = sum_ms / npass;
+  }
+  return 1;
+}
+
+/* ---- Merge a candidate into the list ---- */
+
+static int merge_candidate(
+  FinderPattern *cands,
+  int nc,
+  int max_cands,
+  float cx,
+  float cy,
+  float ms
+) {
+  float merge_r = ms * 1.5f;
+  for (int c = 0; c < nc; c++) {
+    float dx = cands[c].x - cx;
+    float dy = cands[c].y - cy;
+    if (sqrtf(dx * dx + dy * dy) < merge_r) {
+      cands[c].x = (cands[c].x + cx) / 2;
+      cands[c].y = (cands[c].y + cy) / 2;
+      cands[c].module_size = (cands[c].module_size + ms) / 2;
+      return nc;
+    }
+  }
+  if (nc < max_cands) {
+    cands[nc].x = cx;
+    cands[nc].y = cy;
+    cands[nc].module_size = ms;
+    return nc + 1;
+  }
+  return nc;
+}
+
+/* ---- Scan a single line of pixels for finder patterns ---- */
+
+typedef struct {
+  uint8_t const *px;
+  int w, h;
+  int x0, y0, dx, dy, len;
+} ScanLine;
+
+static int get_dark_at(const ScanLine *sl, int step) {
+  int x = sl->x0 + step * sl->dx;
+  int y = sl->y0 + step * sl->dy;
+  return is_dark(sl->px, sl->w, sl->h, x, y);
+}
+
+static void scan_one_line(
+  const ScanLine *sl,
+  FinderPattern *cands,
+  int *nc,
+  int max_cands
+) {
+  int s[5] = {0};
+  int st = 0;
+
+  for (int i = 0; i < sl->len; i++) {
+    int dark = get_dark_at(sl, i);
+    if (dark) {
+      if (st == 1 || st == 3) {
+        st++;
+      }
+      s[st]++;
+    }
+    else {
+      if (st == 0 || st == 2) {
+        st++;
+        s[st]++;
+      }
+      else if (st == 4) {
+        if (check_ratio(s)) {
+          int total = s[0] + s[1] + s[2] + s[3] + s[4];
+          float center_step = (float)(i - s[4] - s[3]) - (float)s[2] / 2.0f;
+          float cx = sl->x0 + center_step * sl->dx;
+          float cy = sl->y0 + center_step * sl->dy;
+          float ms = (float)total / 7.0f;
+
+          float rcx, rcy, rms;
+          if (cross_check_radial(
+                sl->px,
+                sl->w,
+                sl->h,
+                cx,
+                cy,
+                ms,
+                &rcx,
+                &rcy,
+                &rms
+              )) {
+            *nc = merge_candidate(cands, *nc, max_cands, rcx, rcy, rms);
+          }
+        }
+        s[0] = s[2];
+        s[1] = s[3];
+        s[2] = s[4];
+        s[3] = 1;
+        s[4] = 0;
+        st = 3;
+      }
+      else {
+        s[st]++;
+      }
+    }
+  }
+}
+
+/* ---- Find finder patterns with multi-direction scanning ---- */
+
+static int
+find_finders(uint8_t const *px, int w, int h, FinderPattern *out, int max_out) {
+  FinderPattern cands[MAX_CANDIDATES];
+  int nc = 0;
+  ScanLine sl;
+  sl.px = px;
+  sl.w = w;
+  sl.h = h;
+
+  /* Direction 1: Horizontal */
+  sl.dx = 1;
+  sl.dy = 0;
+  for (int y = 0; y < h && nc < MAX_CANDIDATES; y++) {
+    sl.x0 = 0;
+    sl.y0 = y;
+    sl.len = w;
+    scan_one_line(&sl, cands, &nc, MAX_CANDIDATES);
+  }
+
+  /* Direction 2: Vertical */
+  sl.dx = 0;
+  sl.dy = 1;
+  for (int x = 0; x < w && nc < MAX_CANDIDATES; x++) {
+    sl.x0 = x;
+    sl.y0 = 0;
+    sl.len = h;
+    scan_one_line(&sl, cands, &nc, MAX_CANDIDATES);
+  }
+
+  /* Direction 3: Diagonal 45° */
+  sl.dx = 1;
+  sl.dy = 1;
+  for (int y = 0; y < h && nc < MAX_CANDIDATES; y++) {
+    sl.x0 = 0;
+    sl.y0 = y;
+    sl.len = (w < h - y) ? w : h - y;
+    scan_one_line(&sl, cands, &nc, MAX_CANDIDATES);
+  }
+  for (int x = 1; x < w && nc < MAX_CANDIDATES; x++) {
+    sl.x0 = x;
+    sl.y0 = 0;
+    sl.len = (w - x < h) ? w - x : h;
+    scan_one_line(&sl, cands, &nc, MAX_CANDIDATES);
+  }
+
+  /* Direction 4: Diagonal 135° */
+  sl.dx = 1;
+  sl.dy = -1;
+  for (int y = 0; y < h && nc < MAX_CANDIDATES; y++) {
+    sl.x0 = 0;
+    sl.y0 = y;
+    sl.len = (w < y + 1) ? w : y + 1;
+    scan_one_line(&sl, cands, &nc, MAX_CANDIDATES);
+  }
+  for (int x = 1; x < w && nc < MAX_CANDIDATES; x++) {
+    sl.x0 = x;
+    sl.y0 = h - 1;
+    sl.len = (w - x < h) ? w - x : h;
+    scan_one_line(&sl, cands, &nc, MAX_CANDIDATES);
+  }
+
+  int n = (nc < max_out) ? nc : max_out;
+  for (int i = 0; i < n; i++) {
+    out[i] = cands[i];
+  }
+  return n;
+}
+
+/* ---- Subpixel finder center refinement ----
+   Once a candidate center is detected, refine it by computing the weighted
+   centroid of dark pixels in the inner 3x3-module dark square. This fixes
+   the ±2-3 pixel systematic bias from integer-pixel 1D scans, which
+   otherwise throws off grid sampling under perspective distortion. */
+static void
+refine_finder_center(uint8_t const *gray, int w, int h, FinderPattern *fp) {
+  float ms = fp->module_size;
+  if (ms < 1.0f) {
+    return;
+  }
+  /* Search in a 3-module window centered on the current estimate so the
+     centroid is dominated by the inner 3x3 dark core and not the outer
+     7x7 dark ring (which would pull the result off-center). */
+  int r = (int)(ms * 1.5f);
+  int x0 = (int)(fp->x + 0.5f) - r;
+  int x1 = (int)(fp->x + 0.5f) + r;
+  int y0 = (int)(fp->y + 0.5f) - r;
+  int y1 = (int)(fp->y + 0.5f) + r;
+  if (x0 < 0) {
+    x0 = 0;
+  }
+  if (y0 < 0) {
+    y0 = 0;
+  }
+  if (x1 >= w) {
+    x1 = w - 1;
+  }
+  if (y1 >= h) {
+    y1 = h - 1;
+  }
+  if (x1 <= x0 || y1 <= y0) {
+    return;
+  }
+  /* Compute local min/max to threshold adaptively within this tiny window. */
+  uint8_t lo = 255, hi = 0;
+  for (int y = y0; y <= y1; y++) {
+    for (int x = x0; x <= x1; x++) {
+      uint8_t v = gray[y * w + x];
+      if (v < lo) {
+        lo = v;
+      }
+      if (v > hi) {
+        hi = v;
+      }
+    }
+  }
+  if (hi <= lo + 10) {
+    return; /* no contrast — leave center alone */
+  }
+  int t = (lo + hi) / 2;
+  double sx = 0, sy = 0, sw = 0;
+  for (int y = y0; y <= y1; y++) {
+    for (int x = x0; x <= x1; x++) {
+      uint8_t v = gray[y * w + x];
+      if (v < t) {
+        double weight = (double)(t - v);
+        sx += (double)x * weight;
+        sy += (double)y * weight;
+        sw += weight;
+      }
+    }
+  }
+  if (sw > 0) {
+    fp->x = (float)(sx / sw);
+    fp->y = (float)(sy / sw);
+  }
+}
+
+/* ---- Identify TL, TR, BL ---- */
+
+static float fp_dist(FinderPattern a, FinderPattern b) {
+  float dx = a.x - b.x, dy = a.y - b.y;
+  return sqrtf(dx * dx + dy * dy);
+}
+
+static void identify_triple(
+  FinderPattern a,
+  FinderPattern b,
+  FinderPattern c,
+  FinderPattern *tl,
+  FinderPattern *tr,
+  FinderPattern *bl
+) {
+  *tl = a;
+  float cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
+  if (cross > 0) {
+    *tr = b;
+    *bl = c;
+  }
+  else {
+    *tr = c;
+    *bl = b;
+  }
+}
+
+/* ---- Timing pattern verification ----
+   Walk the candidate timing row between two finder centers, offset 3
+   modules perpendicular toward the third finder (finder center is at
+   module row 3, timing pattern at module row 6). A real QR timing row
+   shows alternating dark/light modules at module-size pitch; incidental
+   1:1:3:1:1 "finder" triples in random data do not. Returns transitions
+   per step in [0,1]; ≥ 0.75 reliably indicates a timing pattern (random
+   data averages 0.5 with 95% CI up to ~0.67). */
+static float timing_score_pair(
+  uint8_t const *px,
+  int w,
+  int h,
+  FinderPattern a,
+  FinderPattern b,
+  FinderPattern c_toward
+) {
+  float ms = (a.module_size + b.module_size) / 2.0f;
+  if (ms < 1.0f) {
+    return 0;
+  }
+  float ux = b.x - a.x, uy = b.y - a.y;
+  float u_len = sqrtf(ux * ux + uy * uy);
+  if (u_len < 10.0f * ms) {
+    return 0;
+  }
+  ux /= u_len;
+  uy /= u_len;
+  float perp_x = -uy, perp_y = ux;
+  float to_c_x = c_toward.x - a.x, to_c_y = c_toward.y - a.y;
+  if (perp_x * to_c_x + perp_y * to_c_y < 0) {
+    perp_x = -perp_x;
+    perp_y = -perp_y;
+  }
+  /* Finder center is the center of module (3,3); timing pattern runs along
+     the center of module (·,6). Offset from finder-center line to timing
+     line is 6 − 3 = 3 modules. */
+  float offset_x = 3.0f * ms * perp_x;
+  float offset_y = 3.0f * ms * perp_y;
+  float sx = a.x + 4.0f * ms * ux + offset_x;
+  float sy = a.y + 4.0f * ms * uy + offset_y;
+  float ex = b.x - 4.0f * ms * ux + offset_x;
+  float ey = b.y - 4.0f * ms * uy + offset_y;
+  float walk_len = sqrtf((ex - sx) * (ex - sx) + (ey - sy) * (ey - sy));
+  int n_steps = (int)(walk_len / ms + 0.5f);
+  if (n_steps < 6 || n_steps > 165) {
+    return 0;
+  }
+  int prev_dark = -1;
+  int transitions = 0;
+  int samples = 0;
+  for (int i = 0; i <= n_steps; i++) {
+    float t = (float)i / (float)n_steps;
+    float fx = sx + t * (ex - sx);
+    float fy = sy + t * (ey - sy);
+    int ix = (int)(fx + 0.5f), iy = (int)(fy + 0.5f);
+    if (ix < 0 || ix >= w || iy < 0 || iy >= h) {
+      continue;
+    }
+    int dark = is_dark(px, w, h, ix, iy);
+    samples++;
+    if (prev_dark != -1 && dark != prev_dark) {
+      transitions++;
+    }
+    prev_dark = dark;
+  }
+  if (samples < 5) {
+    return 0;
+  }
+  return (float)transitions / (float)(samples - 1);
+}
+
+static int select_and_rank(
+  uint8_t const *px,
+  int w,
+  int h,
+  FinderPattern *fps,
+  int nfp,
+  FinderPattern triples[][3],
+  int max_triples
+) {
+  if (nfp < 3) {
+    return 0;
+  }
+
+  /* Count ms-cluster partners for each candidate: how many other candidates
+     have module_size within 15% of mine. Real finder triples are three
+     near-identical-ms blobs; scoring-by-ms alone lets wide-run false
+     positives in dense data regions push the real triple out of the
+     search window on cluttered images (e.g. receipt QR next to a
+     barcode). Clustered candidates get promoted so the triple survives. */
+  int cluster_count[MAX_CANDIDATES];
+  for (int i = 0; i < nfp; i++) {
+    int count = 0;
+    for (int j = 0; j < nfp; j++) {
+      if (j == i) {
+        continue;
+      }
+      float m = (fps[i].module_size > 0.001f) ? fps[i].module_size : 0.001f;
+      float ratio = fabsf(fps[i].module_size - fps[j].module_size) / m;
+      if (ratio < 0.15f) {
+        count++;
+      }
+    }
+    cluster_count[i] = count;
+  }
+
+  /* Selection sort by (clustered desc, module_size desc). Candidates with
+     ≥ 2 ms-partners come first; within each group, larger ms wins. */
+  for (int i = 0; i < nfp - 1; i++) {
+    int best = i;
+    for (int j = i + 1; j < nfp; j++) {
+      int j_cl = cluster_count[j] >= 2;
+      int b_cl = cluster_count[best] >= 2;
+      int replace = 0;
+      if (j_cl && !b_cl) {
+        replace = 1;
+      }
+      else if (j_cl == b_cl && fps[j].module_size > fps[best].module_size) {
+        replace = 1;
+      }
+      if (replace) {
+        best = j;
+      }
+    }
+    if (best != i) {
+      FinderPattern tmp = fps[i];
+      fps[i] = fps[best];
+      fps[best] = tmp;
+      int tc = cluster_count[i];
+      cluster_count[i] = cluster_count[best];
+      cluster_count[best] = tc;
+    }
+  }
+
+  typedef struct {
+    float score;
+    int i, j, k;
+  } Scored;
+  int max_scored = max_triples * 4;
+  if (max_scored > 200) {
+    max_scored = 200;
+  }
+  Scored *scored = malloc(max_scored * sizeof(Scored));
+  if (!scored) {
+    return 0;
+  }
+  int ns = 0;
+
+  /* Consider all clustered candidates (capped at 80 for O(n³) budget), or at
+     least 30 if few candidates cluster — preserves original behaviour on
+     noise-only scenes while accommodating crowded ones. */
+  int num_clustered = 0;
+  for (int i = 0; i < nfp; i++) {
+    if (cluster_count[i] >= 2) {
+      num_clustered++;
+    }
+  }
+  int limit = num_clustered;
+  if (limit < 30) {
+    limit = 30;
+  }
+  if (limit > nfp) {
+    limit = nfp;
+  }
+  if (limit > 80) {
+    limit = 80;
+  }
+  for (int i = 0; i < limit; i++) {
+    for (int j = i + 1; j < limit; j++) {
+      for (int k = j + 1; k < limit; k++) {
+        float ms_avg =
+          (fps[i].module_size + fps[j].module_size + fps[k].module_size) / 3.0f;
+        float ms_var = (fabsf(fps[i].module_size - ms_avg) +
+                        fabsf(fps[j].module_size - ms_avg) +
+                        fabsf(fps[k].module_size - ms_avg)) /
+                       ms_avg;
+
+        float d01 = fp_dist(fps[i], fps[j]);
+        float d02 = fp_dist(fps[i], fps[k]);
+        float d12 = fp_dist(fps[j], fps[k]);
+
+        float sides[3] = {d01, d02, d12};
+        for (int a = 0; a < 2; a++) {
+          for (int b = a + 1; b < 3; b++) {
+            if (sides[a] > sides[b]) {
+              float t = sides[a];
+              sides[a] = sides[b];
+              sides[b] = t;
+            }
+          }
+        }
+
+        float side_ratio = (sides[1] > 0) ? sides[0] / sides[1] : 0;
+        float expected_hyp = sqrtf(sides[0] * sides[0] + sides[1] * sides[1]);
+        float hyp_err = (expected_hyp > 0)
+                          ? fabsf(sides[2] - expected_hyp) / expected_hyp
+                          : 1;
+
+        /* Real QR finder centers sit at module (3.5, 3.5), (sz-3.5, 3.5),
+           (3.5, sz-3.5) — so the short side divided by module size should be
+           qr_size - 7, at least 14 for v1. Triples formed from incidental
+           1:1:3:1:1 patterns in adjacent data modules have small
+           sides/ms ratios; saturate the bonus at 14 so real finders outrank
+           them without giving oversized triples unbounded advantage. */
+        float modules_between = (ms_avg > 0) ? sides[0] / ms_avg : 0;
+        /* QR spec: finder-center distance = (qr_size − 7) × module_size, with
+           qr_size ∈ [21, 177]. So the longer leg cannot plausibly exceed
+           ~170 modules; leave ~10% slack for perspective and scan noise. A
+           triple spanning an entire receipt/page at some spurious module size
+           blows past this even when its module sizes happen to cluster. */
+        float modules_between_long = (ms_avg > 0) ? sides[1] / ms_avg : 0;
+        if (modules_between_long > 190.0f) {
+          continue;
+        }
+        float scale_bonus = modules_between / 14.0f;
+        if (scale_bonus > 1.0f) {
+          scale_bonus = 1.0f;
+        }
+        /* ms_var dominates: real QR finders have near-identical module sizes;
+           triples whose "finders" are false positives at different scales
+           get rejected hard. Discount hyp_err so moderate perspective
+           distortion doesn't kill the true triple. */
+        float score = side_ratio - ms_var * 5.0f - hyp_err * 1.0f + scale_bonus;
+
+        /* Timing-pattern bonus: real finder triples have two pairs whose
+           inter-finder line runs along a QR timing row of alternating
+           dark/light modules. False-positive triples in dense document/
+           receipt text rarely hit this, so the bonus of +1 per timing
+           pair lets perspective-distorted real triples outrank clean but
+           incidental right-triangle triples. Gate on threshold 0.75 —
+           random data ratio is ≤ 0.67 with 95% confidence. */
+        int timing_pairs = 0;
+        float t_ij = timing_score_pair(px, w, h, fps[i], fps[j], fps[k]);
+        float t_ik = timing_score_pair(px, w, h, fps[i], fps[k], fps[j]);
+        float t_jk = timing_score_pair(px, w, h, fps[j], fps[k], fps[i]);
+        if (t_ij >= 0.75f) {
+          timing_pairs++;
+        }
+        if (t_ik >= 0.75f) {
+          timing_pairs++;
+        }
+        if (t_jk >= 0.75f) {
+          timing_pairs++;
+        }
+        score += (float)timing_pairs;
+
+        if (ns < max_scored) {
+          scored[ns++] = (Scored){score, i, j, k};
+        }
+        else {
+          int worst = 0;
+          for (int s = 1; s < ns; s++) {
+            if (scored[s].score < scored[worst].score) {
+              worst = s;
+            }
+          }
+          if (score > scored[worst].score) {
+            scored[worst] = (Scored){score, i, j, k};
+          }
+        }
+      }
+    }
+  }
+
+  for (int i = 0; i < ns - 1; i++) {
+    for (int j = i + 1; j < ns; j++) {
+      if (scored[j].score > scored[i].score) {
+        Scored t = scored[i];
+        scored[i] = scored[j];
+        scored[j] = t;
+      }
+    }
+  }
+
+  int out_count = (ns < max_triples) ? ns : max_triples;
+  for (int t = 0; t < out_count; t++) {
+    FinderPattern triple[3] =
+      {fps[scored[t].i], fps[scored[t].j], fps[scored[t].k]};
+    float d01 = fp_dist(triple[0], triple[1]);
+    float d02 = fp_dist(triple[0], triple[2]);
+    float d12 = fp_dist(triple[1], triple[2]);
+    FinderPattern a, b, c;
+    if (d01 >= d02 && d01 >= d12) {
+      a = triple[2];
+      b = triple[0];
+      c = triple[1];
+    }
+    else if (d02 >= d01 && d02 >= d12) {
+      a = triple[1];
+      b = triple[0];
+      c = triple[2];
+    }
+    else {
+      a = triple[0];
+      b = triple[1];
+      c = triple[2];
+    }
+    identify_triple(a, b, c, &triples[t][0], &triples[t][1], &triples[t][2]);
+  }
+
+  free(scored);
+  return out_count;
+}
+
+/* ---- Homography (perspective) ---- */
+
+/* Solve an 8x8 linear system for a perspective transform that maps 4 source
+   points to 4 destination points. Returns 1 on success, 0 if singular. */
+static int compute_homography(
+  const double src[4][2],
+  const double dst[4][2],
+  double H[9]
+) {
+  double A[8][9];
+  memset(A, 0, sizeof(A));
+  for (int i = 0; i < 4; i++) {
+    double sx = src[i][0], sy = src[i][1];
+    double dx = dst[i][0], dy = dst[i][1];
+    A[2 * i][0] = sx;
+    A[2 * i][1] = sy;
+    A[2 * i][2] = 1.0;
+    A[2 * i][6] = -sx * dx;
+    A[2 * i][7] = -sy * dx;
+    A[2 * i][8] = dx;
+    A[2 * i + 1][3] = sx;
+    A[2 * i + 1][4] = sy;
+    A[2 * i + 1][5] = 1.0;
+    A[2 * i + 1][6] = -sx * dy;
+    A[2 * i + 1][7] = -sy * dy;
+    A[2 * i + 1][8] = dy;
+  }
+  for (int i = 0; i < 8; i++) {
+    int max_row = i;
+    double max_val = fabs(A[i][i]);
+    for (int k = i + 1; k < 8; k++) {
+      if (fabs(A[k][i]) > max_val) {
+        max_val = fabs(A[k][i]);
+        max_row = k;
+      }
+    }
+    if (max_val < 1e-10) {
+      return 0;
+    }
+    if (max_row != i) {
+      for (int j = 0; j <= 8; j++) {
+        double t = A[i][j];
+        A[i][j] = A[max_row][j];
+        A[max_row][j] = t;
+      }
+    }
+    for (int j = i + 1; j < 8; j++) {
+      double f = A[j][i] / A[i][i];
+      for (int k = i; k <= 8; k++) {
+        A[j][k] -= f * A[i][k];
+      }
+    }
+  }
+  for (int i = 7; i >= 0; i--) {
+    if (fabs(A[i][i]) < 1e-10) {
+      return 0;
+    }
+    double s = A[i][8];
+    for (int j = i + 1; j < 8; j++) {
+      s -= A[i][j] * H[j];
+    }
+    H[i] = s / A[i][i];
+    if (isnan(H[i]) || isinf(H[i])) {
+      return 0;
+    }
+  }
+  H[8] = 1.0;
+  return 1;
+}
+
+static void apply_homography(
+  const double H[9],
+  double mx,
+  double my,
+  float *out_x,
+  float *out_y
+) {
+  double w = H[6] * mx + H[7] * my + H[8];
+  if (fabs(w) < 1e-10) {
+    *out_x = 0;
+    *out_y = 0;
+    return;
+  }
+  double x = (H[0] * mx + H[1] * my + H[2]) / w;
+  double y = (H[3] * mx + H[4] * my + H[5]) / w;
+  /* Clamp to a range safely castable to int by callers. Values outside this
+     range come from ill-conditioned homographies and would be off-image
+     anyway; unclamped they trigger UB when cast via (int)fx. */
+  const double lim = 1.0e7;
+  if (!(x == x) || x < -lim) {
+    x = -lim;
+  }
+  else if (x > lim) {
+    x = lim;
+  }
+  if (!(y == y) || y < -lim) {
+    y = -lim;
+  }
+  else if (y > lim) {
+    y = lim;
+  }
+  *out_x = (float)x;
+  *out_y = (float)y;
+}
+
+/* Solve the linear system Mh = v where M is 8x8 symmetric (A^T A) and v is
+   8x1 (A^T b), by Gaussian elimination with partial pivoting. Writes the
+   result into the first 8 slots of H and sets H[8] = 1. Returns 1 on
+   success, 0 on singular / ill-conditioned input. */
+static int solve_8x8(double M[8][8], const double v[8], double H[9]) {
+  double A[8][9];
+  for (int i = 0; i < 8; i++) {
+    for (int j = 0; j < 8; j++) {
+      A[i][j] = M[i][j];
+    }
+    A[i][8] = v[i];
+  }
+  for (int i = 0; i < 8; i++) {
+    int max_row = i;
+    double max_val = fabs(A[i][i]);
+    for (int k = i + 1; k < 8; k++) {
+      if (fabs(A[k][i]) > max_val) {
+        max_val = fabs(A[k][i]);
+        max_row = k;
+      }
+    }
+    if (max_val < 1e-12) {
+      return 0;
+    }
+    if (max_row != i) {
+      for (int j = 0; j <= 8; j++) {
+        double t = A[i][j];
+        A[i][j] = A[max_row][j];
+        A[max_row][j] = t;
+      }
+    }
+    for (int k = i + 1; k < 8; k++) {
+      double f = A[k][i] / A[i][i];
+      for (int j = i; j <= 8; j++) {
+        A[k][j] -= f * A[i][j];
+      }
+    }
+  }
+  for (int i = 7; i >= 0; i--) {
+    if (fabs(A[i][i]) < 1e-12) {
+      return 0;
+    }
+    double s = A[i][8];
+    for (int j = i + 1; j < 8; j++) {
+      s -= A[i][j] * H[j];
+    }
+    H[i] = s / A[i][i];
+    if (isnan(H[i]) || isinf(H[i])) {
+      return 0;
+    }
+  }
+  H[8] = 1.0;
+  return 1;
+}
+
+/* Solve an over-determined homography system from N≥4 correspondences via
+   normal equations: the 2N×8 constraint matrix A is folded into an 8×8
+   symmetric M = A^T A and an 8×1 vector v = A^T b, and the 8×8 system is
+   solved directly. No Hartley normalisation — QR decoding stays within a
+   few hundred pixels so the coordinate magnitudes don't blow out double
+   precision here. Returns 1 on success, 0 on singular input. */
+static int compute_homography_ls(
+  const double src[][2],
+  const double dst[][2],
+  int n,
+  double H[9]
+) {
+  if (n < 4) {
+    return 0;
+  }
+  double M[8][8];
+  double v[8];
+  memset(M, 0, sizeof(M));
+  memset(v, 0, sizeof(v));
+
+  for (int i = 0; i < n; i++) {
+    double X = src[i][0], Y = src[i][1];
+    double x = dst[i][0], y = dst[i][1];
+
+    /* x-equation row: [X, Y, 1, 0, 0, 0, -x*X, -x*Y] with RHS = x */
+    double rx[8] = {X, Y, 1, 0, 0, 0, -x * X, -x * Y};
+    for (int a = 0; a < 8; a++) {
+      for (int b = 0; b < 8; b++) {
+        M[a][b] += rx[a] * rx[b];
+      }
+      v[a] += rx[a] * x;
+    }
+
+    /* y-equation row: [0, 0, 0, X, Y, 1, -y*X, -y*Y] with RHS = y */
+    double ry[8] = {0, 0, 0, X, Y, 1, -y * X, -y * Y};
+    for (int a = 0; a < 8; a++) {
+      for (int b = 0; b < 8; b++) {
+        M[a][b] += ry[a] * ry[b];
+      }
+      v[a] += ry[a] * y;
+    }
+  }
+
+  return solve_8x8(M, v, H);
+}
+
+/* Solve an NxN linear system M x = v by Gaussian elimination with partial
+   pivoting. M is destroyed. Result written into x. Returns 1 on success,
+   0 on singular matrix. */
+static int solve_nxn(double *M, double *v, int n, double *x) {
+  /* Augment [M | v] into A: A is n × (n+1), row-major. */
+  int stride = n + 1;
+  double *A = (double *)malloc((size_t)n * (size_t)stride * sizeof(double));
+  if (!A) {
+    return 0;
+  }
+  for (int i = 0; i < n; i++) {
+    for (int j = 0; j < n; j++) {
+      A[i * stride + j] = M[i * n + j];
+    }
+    A[i * stride + n] = v[i];
+  }
+  for (int i = 0; i < n; i++) {
+    int max_row = i;
+    double max_val = fabs(A[i * stride + i]);
+    for (int k = i + 1; k < n; k++) {
+      double av = fabs(A[k * stride + i]);
+      if (av > max_val) {
+        max_val = av;
+        max_row = k;
+      }
+    }
+    if (max_val < 1e-12) {
+      free(A);
+      return 0;
+    }
+    if (max_row != i) {
+      for (int j = 0; j <= n; j++) {
+        double t = A[i * stride + j];
+        A[i * stride + j] = A[max_row * stride + j];
+        A[max_row * stride + j] = t;
+      }
+    }
+    for (int k = i + 1; k < n; k++) {
+      double f = A[k * stride + i] / A[i * stride + i];
+      for (int j = i; j <= n; j++) {
+        A[k * stride + j] -= f * A[i * stride + j];
+      }
+    }
+  }
+  for (int i = n - 1; i >= 0; i--) {
+    if (fabs(A[i * stride + i]) < 1e-12) {
+      free(A);
+      return 0;
+    }
+    double s = A[i * stride + n];
+    for (int j = i + 1; j < n; j++) {
+      s -= A[i * stride + j] * x[j];
+    }
+    x[i] = s / A[i * stride + i];
+    if (isnan(x[i]) || isinf(x[i])) {
+      free(A);
+      return 0;
+    }
+  }
+  free(A);
+  return 1;
+}
+
+/* Fit a quadratic warp (u, v) -> (x, y) using N correspondences. The warp
+   is a bivariate polynomial of degree 2 per output coordinate, i.e.
+       x(u,v) = a0 + a1·u + a2·v + a3·u·v + a4·u² + a5·v²
+       y(u,v) = b0 + b1·u + b2·v + b3·u·v + b4·u² + b5·v²
+   — 12 parameters fit via independent 6-unknown normal equations per
+   coordinate. Needs ≥ 6 observations and they must span a 2-D region
+   (all points on one line gives a singular matrix). Writes 12 floats
+   into coeffs: indices 0..5 are the x-coefficients in the order above,
+   6..11 are the y-coefficients. Returns 1 on success, 0 on failure.
+
+   A quadratic warp can model paper curl and moderate lens/fisheye
+   distortion that a single 8-parameter homography can't. It's not
+   perspective-correct for truly flat surfaces, but on real-world
+   photographs of QR codes on non-planar surfaces (receipts, folded
+   paper, curved displays) it typically gives sub-pixel accuracy where
+   the homography drifts by 1–2 modules across the data area. */
+static int fit_quadratic_warp(
+  const double src[][2],
+  const double dst[][2],
+  int n,
+  double coeffs[12]
+) {
+  if (n < 6) {
+    return 0;
+  }
+  double Mx[36], vx[6];
+  double My[36], vy[6];
+  memset(Mx, 0, sizeof(Mx));
+  memset(My, 0, sizeof(My));
+  memset(vx, 0, sizeof(vx));
+  memset(vy, 0, sizeof(vy));
+  for (int i = 0; i < n; i++) {
+    double u = src[i][0], v = src[i][1];
+    double x = dst[i][0], y = dst[i][1];
+    double row[6] = {1.0, u, v, u * v, u * u, v * v};
+    for (int a = 0; a < 6; a++) {
+      for (int b = 0; b < 6; b++) {
+        Mx[a * 6 + b] += row[a] * row[b];
+        My[a * 6 + b] += row[a] * row[b];
+      }
+      vx[a] += row[a] * x;
+      vy[a] += row[a] * y;
+    }
+  }
+  double cx[6] = {0}, cy[6] = {0};
+  if (!solve_nxn(Mx, vx, 6, cx) || !solve_nxn(My, vy, 6, cy)) {
+    return 0;
+  }
+  for (int i = 0; i < 6; i++) {
+    coeffs[i] = cx[i];
+    coeffs[i + 6] = cy[i];
+  }
+  return 1;
+}
+
+/* Apply a quadratic warp to map a (u, v) module-space point to a (px, py)
+   pixel-space point. Matches the coefficient layout of fit_quadratic_warp. */
+static void apply_quadratic_warp(
+  const double coeffs[12],
+  double u,
+  double v,
+  double *px,
+  double *py
+) {
+  *px = coeffs[0] + coeffs[1] * u + coeffs[2] * v + coeffs[3] * u * v +
+        coeffs[4] * u * u + coeffs[5] * v * v;
+  *py = coeffs[6] + coeffs[7] * u + coeffs[8] * v + coeffs[9] * u * v +
+        coeffs[10] * u * u + coeffs[11] * v * v;
+}
+
+/* Light-pixel analogue of find_dark_centroid_local — centroid of *light*
+   pixels in a local window. Used for known-light QR cells (e.g. the light
+   timing modules, the light 3x3 interior of the finder pattern) to add
+   anchor points between the dark modules the primary observer finds. */
+static int find_light_centroid_local(
+  uint8_t const *gray,
+  int w,
+  int h,
+  double px_pred,
+  double py_pred,
+  float search_r,
+  double *px_obs,
+  double *py_obs
+) {
+  int r = (int)(search_r + 0.5f);
+  if (r < 2) {
+    r = 2;
+  }
+  int cx = (int)(px_pred + 0.5);
+  int cy = (int)(py_pred + 0.5);
+  int x0 = cx - r;
+  int y0 = cy - r;
+  int x1 = cx + r;
+  int y1 = cy + r;
+  if (x0 < 0) {
+    x0 = 0;
+  }
+  if (y0 < 0) {
+    y0 = 0;
+  }
+  if (x1 >= w) {
+    x1 = w - 1;
+  }
+  if (y1 >= h) {
+    y1 = h - 1;
+  }
+  if (x1 - x0 < 2 || y1 - y0 < 2) {
+    return 0;
+  }
+  uint8_t lo = 255, hi = 0;
+  for (int y = y0; y <= y1; y++) {
+    for (int x = x0; x <= x1; x++) {
+      uint8_t g = gray[y * w + x];
+      if (g < lo) {
+        lo = g;
+      }
+      if (g > hi) {
+        hi = g;
+      }
+    }
+  }
+  if (hi - lo < 20) {
+    return 0;
+  }
+  int t = (lo + hi) / 2;
+  double sx = 0, sy = 0, sw = 0;
+  for (int y = y0; y <= y1; y++) {
+    for (int x = x0; x <= x1; x++) {
+      uint8_t g = gray[y * w + x];
+      if (g >= t) {
+        double weight = (double)(g - t);
+        sx += (double)x * weight;
+        sy += (double)y * weight;
+        sw += weight;
+      }
+    }
+  }
+  if (sw <= 0) {
+    return 0;
+  }
+  *px_obs = sx / sw;
+  *py_obs = sy / sw;
+  return 1;
+}
+
+/* Observe the pixel-space centroid of dark pixels in a local window around
+   a predicted position, using an adaptive local threshold. Used to ground-
+   truth the position of a known-dark QR module (timing pattern dark
+   module, alignment center, etc.) against the pixel buffer so we can
+   refine a homography that was built from far-apart control points.
+   Returns 1 on success; 0 if the window is uniform (low contrast ⇒
+   unreliable). */
+static int find_dark_centroid_local(
+  uint8_t const *gray,
+  int w,
+  int h,
+  double px_pred,
+  double py_pred,
+  float search_r,
+  double *px_obs,
+  double *py_obs
+) {
+  int r = (int)(search_r + 0.5f);
+  if (r < 2) {
+    r = 2;
+  }
+  int cx = (int)(px_pred + 0.5);
+  int cy = (int)(py_pred + 0.5);
+  int x0 = cx - r;
+  int y0 = cy - r;
+  int x1 = cx + r;
+  int y1 = cy + r;
+  if (x0 < 0) {
+    x0 = 0;
+  }
+  if (y0 < 0) {
+    y0 = 0;
+  }
+  if (x1 >= w) {
+    x1 = w - 1;
+  }
+  if (y1 >= h) {
+    y1 = h - 1;
+  }
+  if (x1 - x0 < 2 || y1 - y0 < 2) {
+    return 0;
+  }
+  uint8_t lo = 255, hi = 0;
+  for (int y = y0; y <= y1; y++) {
+    for (int x = x0; x <= x1; x++) {
+      uint8_t g = gray[y * w + x];
+      if (g < lo) {
+        lo = g;
+      }
+      if (g > hi) {
+        hi = g;
+      }
+    }
+  }
+  if (hi - lo < 20) {
+    return 0;
+  }
+  int t = (lo + hi) / 2;
+  double sx = 0, sy = 0, sw = 0;
+  for (int y = y0; y <= y1; y++) {
+    for (int x = x0; x <= x1; x++) {
+      uint8_t g = gray[y * w + x];
+      if (g < t) {
+        double weight = (double)(t - g);
+        sx += (double)x * weight;
+        sy += (double)y * weight;
+        sw += weight;
+      }
+    }
+  }
+  if (sw <= 0) {
+    return 0;
+  }
+  *px_obs = sx / sw;
+  *py_obs = sy / sw;
+  return 1;
+}
+
+/* Forward declarations — defined later alongside the homography decode path. */
+static char *decode_grid(
+  uint8_t *grid,
+  uint8_t const *conf,
+  int qr_size,
+  int version,
+  int *out_fmt_dist,
+  int *out_rs_cost
+);
+static float timing_error_rate(uint8_t const *grid, int qr_size);
+static float finder_error_rate(uint8_t const *grid, int qr_size);
+
+/* Sample the QR module grid using a quadratic warp instead of a homography.
+   Behaves like sample_grid_homography_offset_mode's non-bilinear path but
+   with a polynomial mapping that can bend to follow paper curl. The 5-
+   point sub-sample voting is preserved for tolerance against small
+   within-module drift. Returns a heap buffer of size qr_size*qr_size;
+   caller frees. */
+static uint8_t *sample_grid_quadratic(
+  uint8_t const *px,
+  int w,
+  int h,
+  const double coeffs[12],
+  int qr_size
+) {
+  int total = qr_size * qr_size;
+  uint8_t *grid = calloc(total, 1);
+  if (!grid) {
+    return NULL;
+  }
+  static const double offs[5][2] = {
+    {0.5, 0.5},
+    {0.35, 0.35},
+    {0.65, 0.35},
+    {0.35, 0.65},
+    {0.65, 0.65},
+  };
+  for (int r = 0; r < qr_size; r++) {
+    for (int c = 0; c < qr_size; c++) {
+      int votes = 0;
+      for (int o = 0; o < 5; o++) {
+        double px_d, py_d;
+        apply_quadratic_warp(
+          coeffs,
+          c + offs[o][0],
+          r + offs[o][1],
+          &px_d,
+          &py_d
+        );
+        int ix = (int)(px_d + 0.5);
+        int iy = (int)(py_d + 0.5);
+        if (is_dark(px, w, h, ix, iy)) {
+          votes++;
+        }
+      }
+      grid[r * qr_size + c] = (votes >= 3) ? 1 : 0;
+    }
+  }
+  return grid;
+}
+
+/* Fit a quadratic warp from 3 finder centers + alignment + all observable
+   timing-module centroids, then sample the grid with it and try to
+   decode. Returns a heap-allocated decoded string (caller frees) or
+   NULL if sampling/decode fails. */
+static char *try_decode_quadratic(
+  uint8_t const *bin,
+  uint8_t const *gray,
+  int w,
+  int h,
+  int qr_size,
+  int version,
+  FinderPattern tl,
+  FinderPattern tr,
+  FinderPattern bl,
+  int have_alignment,
+  double alignment_x,
+  double alignment_y,
+  const double H_init[9],
+  float ms,
+  int *out_fmt_dist,
+  int *out_rs_cost
+) {
+  enum { MAX_CORR = 80 };
+  double src[MAX_CORR][2];
+  double dst[MAX_CORR][2];
+  int n = 0;
+  src[n][0] = 3.5;
+  src[n][1] = 3.5;
+  dst[n][0] = tl.x;
+  dst[n][1] = tl.y;
+  n++;
+  src[n][0] = qr_size - 3.5;
+  src[n][1] = 3.5;
+  dst[n][0] = tr.x;
+  dst[n][1] = tr.y;
+  n++;
+  src[n][0] = 3.5;
+  src[n][1] = qr_size - 3.5;
+  dst[n][0] = bl.x;
+  dst[n][1] = bl.y;
+  n++;
+  if (have_alignment) {
+    src[n][0] = qr_size - 6.5;
+    src[n][1] = qr_size - 6.5;
+    dst[n][0] = alignment_x;
+    dst[n][1] = alignment_y;
+    n++;
+  }
+  float search_r = ms * 0.5f;
+  /* Horizontal timing pattern: dark at even cols, light at odd cols. Sample
+     both so the quadratic warp has fine-grained constraints along row 6. */
+  for (int col = 7; col <= qr_size - 8; col++) {
+    if (n >= MAX_CORR) {
+      break;
+    }
+    double mx = (double)col + 0.5;
+    double my = 6.5;
+    double pw = H_init[6] * mx + H_init[7] * my + H_init[8];
+    if (fabs(pw) < 1e-10) {
+      continue;
+    }
+    double px_pred = (H_init[0] * mx + H_init[1] * my + H_init[2]) / pw;
+    double py_pred = (H_init[3] * mx + H_init[4] * my + H_init[5]) / pw;
+    double px_obs, py_obs;
+    int ok_c;
+    if ((col % 2) == 0) {
+      ok_c = find_dark_centroid_local(
+        gray,
+        w,
+        h,
+        px_pred,
+        py_pred,
+        search_r,
+        &px_obs,
+        &py_obs
+      );
+    }
+    else {
+      ok_c = find_light_centroid_local(
+        gray,
+        w,
+        h,
+        px_pred,
+        py_pred,
+        search_r,
+        &px_obs,
+        &py_obs
+      );
+    }
+    if (ok_c) {
+      src[n][0] = mx;
+      src[n][1] = my;
+      dst[n][0] = px_obs;
+      dst[n][1] = py_obs;
+      n++;
+    }
+  }
+  for (int row = 7; row <= qr_size - 8; row++) {
+    if (n >= MAX_CORR) {
+      break;
+    }
+    double mx = 6.5;
+    double my = (double)row + 0.5;
+    double pw = H_init[6] * mx + H_init[7] * my + H_init[8];
+    if (fabs(pw) < 1e-10) {
+      continue;
+    }
+    double px_pred = (H_init[0] * mx + H_init[1] * my + H_init[2]) / pw;
+    double py_pred = (H_init[3] * mx + H_init[4] * my + H_init[5]) / pw;
+    double px_obs, py_obs;
+    int ok_c;
+    if ((row % 2) == 0) {
+      ok_c = find_dark_centroid_local(
+        gray,
+        w,
+        h,
+        px_pred,
+        py_pred,
+        search_r,
+        &px_obs,
+        &py_obs
+      );
+    }
+    else {
+      ok_c = find_light_centroid_local(
+        gray,
+        w,
+        h,
+        px_pred,
+        py_pred,
+        search_r,
+        &px_obs,
+        &py_obs
+      );
+    }
+    if (ok_c) {
+      src[n][0] = mx;
+      src[n][1] = my;
+      dst[n][0] = px_obs;
+      dst[n][1] = py_obs;
+      n++;
+    }
+  }
+  /* Alignment pattern anchors: sample the 8 known-light inner-ring cells
+     around the BR alignment center to pin the quadratic warp's BR quadrant
+     — without this the warp is only constrained by the single alignment
+     center in that region and extrapolates wildly into the data area. */
+  if (have_alignment) {
+    double ax = (double)qr_size - 6.5; /* alignment center in module coords */
+    double ay = (double)qr_size - 6.5;
+    static const double ring1[8][2] = {
+      {-1.0, -1.0},
+      {0.0, -1.0},
+      {1.0, -1.0},
+      {-1.0, 0.0},
+      {1.0, 0.0},
+      {-1.0, 1.0},
+      {0.0, 1.0},
+      {1.0, 1.0},
+    };
+    for (int i = 0; i < 8; i++) {
+      if (n >= MAX_CORR) {
+        break;
+      }
+      double mx = ax + ring1[i][0];
+      double my = ay + ring1[i][1];
+      double pw = H_init[6] * mx + H_init[7] * my + H_init[8];
+      if (fabs(pw) < 1e-10) {
+        continue;
+      }
+      double px_pred = (H_init[0] * mx + H_init[1] * my + H_init[2]) / pw;
+      double py_pred = (H_init[3] * mx + H_init[4] * my + H_init[5]) / pw;
+      double px_obs, py_obs;
+      if (find_light_centroid_local(
+            gray,
+            w,
+            h,
+            px_pred,
+            py_pred,
+            search_r,
+            &px_obs,
+            &py_obs
+          )) {
+        src[n][0] = mx;
+        src[n][1] = my;
+        dst[n][0] = px_obs;
+        dst[n][1] = py_obs;
+        n++;
+      }
+    }
+  }
+  /* Need at least 8 well-spread observations for a stable quadratic fit
+     (6 unknowns per axis + a bit of over-determination). */
+  if (n < 8) {
+    return NULL;
+  }
+  double coeffs[12];
+  if (!fit_quadratic_warp(src, dst, n, coeffs)) {
+    return NULL;
+  }
+  uint8_t *grid = sample_grid_quadratic(bin, w, h, coeffs, qr_size);
+  if (!grid) {
+    return NULL;
+  }
+  int fd = 16, rc = 100000;
+  char *d = decode_grid(grid, NULL, qr_size, version, &fd, &rc);
+  free(grid);
+  if (d) {
+    if (out_fmt_dist) {
+      *out_fmt_dist = fd;
+    }
+    if (out_rs_cost) {
+      *out_rs_cost = rc;
+    }
+  }
+  return d;
+}
+
+/* Refine a homography by observing pixel-space centroids of every dark
+   module in the two QR timing patterns and solving a least-squares fit
+   over the combined set of control points and timing observations. The
+   3 finder centers (and optional BR alignment) give the coarse shape;
+   the timing observations, which thread the QR's interior, constrain
+   the transform through the data area and correct the sub-module drift
+   that a 4-point homography can't resolve on curled or lens-distorted
+   captures (e.g. a receipt photographed at an angle). Returns 1 if the
+   refined H is valid and used ≥ 6 points total. Leaves H_out untouched
+   on failure. */
+static int refine_h_with_timing(
+  uint8_t const *gray,
+  int w,
+  int h,
+  const double H_init[9],
+  int qr_size,
+  float ms,
+  FinderPattern tl,
+  FinderPattern tr,
+  FinderPattern bl,
+  int have_alignment,
+  double alignment_x,
+  double alignment_y,
+  double H_out[9]
+) {
+  if (!gray || qr_size < 21) {
+    return 0;
+  }
+  enum { MAX_CORR = 80 };
+  double src[MAX_CORR][2];
+  double dst[MAX_CORR][2];
+  int n = 0;
+
+  /* 3 finder centers as anchoring control points. */
+  src[n][0] = 3.5;
+  src[n][1] = 3.5;
+  dst[n][0] = tl.x;
+  dst[n][1] = tl.y;
+  n++;
+  src[n][0] = qr_size - 3.5;
+  src[n][1] = 3.5;
+  dst[n][0] = tr.x;
+  dst[n][1] = tr.y;
+  n++;
+  src[n][0] = 3.5;
+  src[n][1] = qr_size - 3.5;
+  dst[n][0] = bl.x;
+  dst[n][1] = bl.y;
+  n++;
+
+  /* Optional alignment-pattern center for the BR corner. */
+  if (have_alignment) {
+    src[n][0] = qr_size - 6.5;
+    src[n][1] = qr_size - 6.5;
+    dst[n][0] = alignment_x;
+    dst[n][1] = alignment_y;
+    n++;
+  }
+
+  /* Timing pattern: module row 6 columns 8..qr_size-9, dark at even cols;
+     module col 6 rows 8..qr_size-9, dark at even rows. Sample the dark
+     modules' pixel-space centroids using the initial H as a guide and a
+     half-module search radius — the initial guess is within a module's
+     width of truth on realistic captures. */
+  float search_r = ms * 0.5f;
+  for (int col = 8; col <= qr_size - 9; col += 2) {
+    if (n >= MAX_CORR) {
+      break;
+    }
+    double mx = (double)col + 0.5;
+    double my = 6.5;
+    double pw = H_init[6] * mx + H_init[7] * my + H_init[8];
+    if (fabs(pw) < 1e-10) {
+      continue;
+    }
+    double px_pred = (H_init[0] * mx + H_init[1] * my + H_init[2]) / pw;
+    double py_pred = (H_init[3] * mx + H_init[4] * my + H_init[5]) / pw;
+    double px_obs, py_obs;
+    if (find_dark_centroid_local(
+          gray,
+          w,
+          h,
+          px_pred,
+          py_pred,
+          search_r,
+          &px_obs,
+          &py_obs
+        )) {
+      src[n][0] = mx;
+      src[n][1] = my;
+      dst[n][0] = px_obs;
+      dst[n][1] = py_obs;
+      n++;
+    }
+  }
+  for (int row = 8; row <= qr_size - 9; row += 2) {
+    if (n >= MAX_CORR) {
+      break;
+    }
+    double mx = 6.5;
+    double my = (double)row + 0.5;
+    double pw = H_init[6] * mx + H_init[7] * my + H_init[8];
+    if (fabs(pw) < 1e-10) {
+      continue;
+    }
+    double px_pred = (H_init[0] * mx + H_init[1] * my + H_init[2]) / pw;
+    double py_pred = (H_init[3] * mx + H_init[4] * my + H_init[5]) / pw;
+    double px_obs, py_obs;
+    if (find_dark_centroid_local(
+          gray,
+          w,
+          h,
+          px_pred,
+          py_pred,
+          search_r,
+          &px_obs,
+          &py_obs
+        )) {
+      src[n][0] = mx;
+      src[n][1] = my;
+      dst[n][0] = px_obs;
+      dst[n][1] = py_obs;
+      n++;
+    }
+  }
+
+  /* Need meaningfully more than the 4-point minimum for the least-squares
+     to actually do anything; if only the control points were observed
+     (all timing centroid calls failed — likely a low-contrast or obscured
+     QR) the refined H would equal the initial one. */
+  if (n < 6) {
+    return 0;
+  }
+  return compute_homography_ls(src, dst, n, H_out);
+}
+
+/* ---- Alignment pattern search ---- */
+
+/* Check if (cx, cy) looks like the center of a QR alignment pattern:
+   dark center with light ring 1 module out and dark ring 2 modules out.
+   Samples along 8 radial directions at 1 and 2 module offsets, and
+   requires at least 6 of 8 each to match. */
+static int
+is_alignment_center(uint8_t const *px, int w, int h, int cx, int cy, float ms) {
+  if (cx < 0 || cx >= w || cy < 0 || cy >= h) {
+    return 0;
+  }
+  if (!is_dark(px, w, h, cx, cy)) {
+    return 0;
+  }
+  /* 8 radial directions */
+  static const float dirs[8][2] = {
+    {1.0f, 0.0f},
+    {0.707f, 0.707f},
+    {0.0f, 1.0f},
+    {-0.707f, 0.707f},
+    {-1.0f, 0.0f},
+    {-0.707f, -0.707f},
+    {0.0f, -1.0f},
+    {0.707f, -0.707f},
+  };
+  int light_ok = 0;
+  int dark_ring_ok = 0;
+  int beyond_light_ok = 0;
+  int beyond_checked = 0;
+  for (int d = 0; d < 8; d++) {
+    int lx = (int)(cx + ms * dirs[d][0] + 0.5f * (dirs[d][0] > 0 ? 1 : -1));
+    int ly = (int)(cy + ms * dirs[d][1] + 0.5f * (dirs[d][1] > 0 ? 1 : -1));
+    int rx =
+      (int)(cx + 2.0f * ms * dirs[d][0] + 0.5f * (dirs[d][0] > 0 ? 1 : -1));
+    int ry =
+      (int)(cy + 2.0f * ms * dirs[d][1] + 0.5f * (dirs[d][1] > 0 ? 1 : -1));
+    /* A real alignment pattern is exactly 5 modules wide: the outer dark
+       ring sits at radius 2·ms, so just beyond (2.8·ms) we must be out of
+       the pattern again. Data-module clusters that happen to expose a
+       dark center + light 1·ms + dark 2·ms often keep going dark past
+       radius 2, because they're embedded in a larger dense region —
+       require "light past the dark ring" in the majority of directions
+       to rule those out. */
+    int bx =
+      (int)(cx + 2.8f * ms * dirs[d][0] + 0.5f * (dirs[d][0] > 0 ? 1 : -1));
+    int by =
+      (int)(cy + 2.8f * ms * dirs[d][1] + 0.5f * (dirs[d][1] > 0 ? 1 : -1));
+    if (lx < 0 || lx >= w || ly < 0 || ly >= h) {
+      continue;
+    }
+    if (rx < 0 || rx >= w || ry < 0 || ry >= h) {
+      continue;
+    }
+    if (!is_dark(px, w, h, lx, ly)) {
+      light_ok++;
+    }
+    if (is_dark(px, w, h, rx, ry)) {
+      dark_ring_ok++;
+    }
+    if (bx >= 0 && bx < w && by >= 0 && by < h) {
+      beyond_checked++;
+      if (!is_dark(px, w, h, bx, by)) {
+        beyond_light_ok++;
+      }
+    }
+  }
+  if (light_ok < 6 || dark_ring_ok < 6) {
+    return 0;
+  }
+  /* Require ≥ 5 of 8 outer directions to be light beyond the dark ring.
+     5 (of 8) keeps a small margin for alignment patterns adjacent to a
+     single dark data module while still killing patterns embedded in
+     dark clusters. If we couldn't check enough directions (pattern near
+     image edge), pass through — the existing 2-ring check has to carry. */
+  if (beyond_checked >= 5 && beyond_light_ok < 5) {
+    return 0;
+  }
+  return 1;
+}
+
+static int find_alignment_pattern(
+  uint8_t const *px,
+  int w,
+  int h,
+  float cx_pred,
+  float cy_pred,
+  float expected_ms,
+  float search_radius,
+  float *out_cx,
+  float *out_cy
+) {
+  int x_min = (int)(cx_pred - search_radius);
+  int x_max = (int)(cx_pred + search_radius);
+  int y_min = (int)(cy_pred - search_radius);
+  int y_max = (int)(cy_pred + search_radius);
+  if (x_min < 0) {
+    x_min = 0;
+  }
+  if (y_min < 0) {
+    y_min = 0;
+  }
+  if (x_max >= w) {
+    x_max = w - 1;
+  }
+  if (y_max >= h) {
+    y_max = h - 1;
+  }
+  if (x_max <= x_min || y_max <= y_min) {
+    return 0;
+  }
+  /* Scan every dark pixel; the one with the smallest distance to the
+     predicted center that also passes the template check wins. */
+  float best_cx = -1, best_cy = -1;
+  float best_dist = 1e30f;
+  for (int y = y_min; y <= y_max; y++) {
+    for (int x = x_min; x <= x_max; x++) {
+      if (!is_dark(px, w, h, x, y)) {
+        continue;
+      }
+      if (!is_alignment_center(px, w, h, x, y, expected_ms)) {
+        continue;
+      }
+      float dx = (float)x - cx_pred;
+      float dy = (float)y - cy_pred;
+      float dist = dx * dx + dy * dy;
+      if (dist < best_dist) {
+        best_dist = dist;
+        best_cx = (float)x;
+        best_cy = (float)y;
+      }
+    }
+  }
+  if (best_cx < 0) {
+    return 0;
+  }
+  /* Refine by averaging all near-center dark pixels (the center module is
+     ~1 module wide, so there will be a cluster of passing pixels). */
+  int r_refine = (int)(expected_ms * 0.5f);
+  if (r_refine < 1) {
+    r_refine = 1;
+  }
+  double sum_x = 0, sum_y = 0;
+  int count = 0;
+  for (int dy = -r_refine; dy <= r_refine; dy++) {
+    for (int dx = -r_refine; dx <= r_refine; dx++) {
+      int x = (int)best_cx + dx;
+      int y = (int)best_cy + dy;
+      if (x < 0 || x >= w || y < 0 || y >= h) {
+        continue;
+      }
+      if (is_dark(px, w, h, x, y) &&
+          is_alignment_center(px, w, h, x, y, expected_ms)) {
+        sum_x += x;
+        sum_y += y;
+        count++;
+      }
+    }
+  }
+  if (count > 0) {
+    *out_cx = (float)(sum_x / count);
+    *out_cy = (float)(sum_y / count);
+  }
+  else {
+    *out_cx = best_cx;
+    *out_cy = best_cy;
+  }
+  return 1;
+}
+
+/* ---- Alignment pattern grid positions (ISO/IEC 18004 Annex E) ----
+   Each row lists the grid coordinates (in modules, 0-indexed) where
+   alignment pattern centres lie for that version. Alignment patterns
+   exist at every combination (row, col) of these values EXCEPT the
+   three that overlap the finder patterns: (first, first), (first, last),
+   (last, first). Index = version. Count is stored in [0]; up to 7 values
+   follow in [1..7]. v1 has no alignment patterns. */
+static const int QR_ALIGN_GRID[41][8] = {
+  {0}, /* v0 unused */
+  {0}, /* v1 */
+  {2, 6, 18},
+  {2, 6, 22},
+  {2, 6, 26},
+  {2, 6, 30},
+  {2, 6, 34},
+  {3, 6, 22, 38},
+  {3, 6, 24, 42},
+  {3, 6, 26, 46},
+  {3, 6, 28, 50},
+  {3, 6, 30, 54},
+  {3, 6, 32, 58},
+  {3, 6, 34, 62},
+  {4, 6, 26, 46, 66},
+  {4, 6, 26, 48, 70},
+  {4, 6, 26, 50, 74},
+  {4, 6, 30, 54, 78},
+  {4, 6, 30, 56, 82},
+  {4, 6, 30, 58, 86},
+  {4, 6, 34, 62, 90},
+  {5, 6, 28, 50, 72, 94},
+  {5, 6, 26, 50, 74, 98},
+  {5, 6, 30, 54, 78, 102},
+  {5, 6, 28, 54, 80, 106},
+  {5, 6, 32, 58, 84, 110},
+  {5, 6, 30, 58, 86, 114},
+  {5, 6, 34, 62, 90, 118},
+  {6, 6, 26, 50, 74, 98, 122},
+  {6, 6, 30, 54, 78, 102, 126},
+  {6, 6, 26, 52, 78, 104, 130},
+  {6, 6, 30, 56, 82, 108, 134},
+  {6, 6, 34, 60, 86, 112, 138},
+  {6, 6, 30, 58, 86, 114, 142},
+  {6, 6, 34, 62, 90, 118, 146},
+  {7, 6, 30, 54, 78, 102, 126, 150},
+  {7, 6, 24, 50, 76, 102, 128, 154},
+  {7, 6, 28, 54, 80, 106, 132, 158},
+  {7, 6, 32, 58, 84, 110, 136, 162},
+  {7, 6, 26, 54, 82, 110, 138, 166},
+  {7, 6, 30, 58, 86, 114, 142, 170},
+};
+
+/* Apply a 3x3 homography H (row-major) to module coordinates (mx, my),
+   returning pixel coordinates. Shared with the quadratic-warp path. */
+static int project_homography(
+  const double H[9],
+  double mx,
+  double my,
+  double *out_px,
+  double *out_py
+) {
+  double w_h = H[6] * mx + H[7] * my + H[8];
+  if (fabs(w_h) < 1e-10) {
+    return 0;
+  }
+  *out_px = (H[0] * mx + H[1] * my + H[2]) / w_h;
+  *out_py = (H[3] * mx + H[4] * my + H[5]) / w_h;
+  return 1;
+}
+
+/* Find every alignment pattern predicted by H for this version, skipping
+   the three that overlap the finders. Writes (module-coord, pixel-coord)
+   pairs for each one found, and returns the count. `search_r_modules`
+   controls how far find_alignment_pattern looks from each predicted
+   centre — widen it when H is an unrefined affine extrapolation from
+   three finders (the residual under strong perspective can be 3-4
+   modules), tighten it when H already folds in the BR alignment. */
+static int find_all_alignment_patterns(
+  uint8_t const *px,
+  int w,
+  int h,
+  int version,
+  const double H[9],
+  float avg_m,
+  float search_r_modules,
+  double out_src[][2],
+  double out_dst[][2],
+  int max_out
+) {
+  if (version < 2 || version > 40) {
+    return 0;
+  }
+  int nvals = QR_ALIGN_GRID[version][0];
+  if (nvals < 2) {
+    return 0;
+  }
+  int last = QR_ALIGN_GRID[version][nvals];
+  int first = QR_ALIGN_GRID[version][1];
+  float search_r = avg_m * search_r_modules;
+  int found = 0;
+  for (int ri = 0; ri < nvals; ri++) {
+    int row = QR_ALIGN_GRID[version][1 + ri];
+    for (int ci = 0; ci < nvals; ci++) {
+      int col = QR_ALIGN_GRID[version][1 + ci];
+      /* Skip the three finder-overlapping positions. */
+      if ((row == first && col == first) || (row == first && col == last) ||
+          (row == last && col == first)) {
+        continue;
+      }
+      if (found >= max_out) {
+        return found;
+      }
+      double mx = (double)col + 0.5;
+      double my = (double)row + 0.5;
+      double pred_x, pred_y;
+      if (!project_homography(H, mx, my, &pred_x, &pred_y)) {
+        continue;
+      }
+      float found_x, found_y;
+      if (find_alignment_pattern(
+            px,
+            w,
+            h,
+            (float)pred_x,
+            (float)pred_y,
+            avg_m,
+            search_r,
+            &found_x,
+            &found_y
+          )) {
+        out_src[found][0] = mx;
+        out_src[found][1] = my;
+        out_dst[found][0] = found_x;
+        out_dst[found][1] = found_y;
+        found++;
+      }
+    }
+  }
+  return found;
+}
+
+/* ---- Grid sampling ----
+   Homography-only: uses a 4-point perspective transform and samples a small
+   cluster of pixels near each module center, taking the majority value to
+   tolerate minor grid misalignment. The affine-equivalent case (no
+   perspective) is expressible as a homography where the 4th point is the
+   parallelogram completion, so a separate affine path isn't needed. */
+/* Bilinear sample of the binary image at fractional pixel (fx, fy). Returns
+   the 0..255 weighted blend of the four neighbouring pixels' dark-ness. */
+static int bilinear_dark(uint8_t const *px, int w, int h, float fx, float fy) {
+  int x0 = (int)floorf(fx);
+  int y0 = (int)floorf(fy);
+  int x1 = x0 + 1;
+  int y1 = y0 + 1;
+  float dx = fx - x0;
+  float dy = fy - y0;
+  if (x0 < 0) {
+    x0 = 0;
+  }
+  if (y0 < 0) {
+    y0 = 0;
+  }
+  if (x1 < 0) {
+    x1 = 0;
+  }
+  if (y1 < 0) {
+    y1 = 0;
+  }
+  if (x0 >= w) {
+    x0 = w - 1;
+  }
+  if (y0 >= h) {
+    y0 = h - 1;
+  }
+  if (x1 >= w) {
+    x1 = w - 1;
+  }
+  if (y1 >= h) {
+    y1 = h - 1;
+  }
+  int v00 = px[y0 * w + x0] < 128 ? 1 : 0;
+  int v01 = px[y0 * w + x1] < 128 ? 1 : 0;
+  int v10 = px[y1 * w + x0] < 128 ? 1 : 0;
+  int v11 = px[y1 * w + x1] < 128 ? 1 : 0;
+  float v0 = v00 * (1.0f - dx) + v01 * dx;
+  float v1 = v10 * (1.0f - dx) + v11 * dx;
+  return (v0 * (1.0f - dy) + v1 * dy) >= 0.5f;
+}
+
+/* Sample a single grayscale value via bilinear interpolation, with edge
+   clamping. Returns 0 if input gray is NULL (signals "no gray available"). */
+static int
+sample_gray_bilinear(uint8_t const *gray, int w, int h, float fx, float fy) {
+  if (!gray) {
+    return -1;
+  }
+  int x0 = (int)floorf(fx);
+  int y0 = (int)floorf(fy);
+  int x1 = x0 + 1;
+  int y1 = y0 + 1;
+  float dx = fx - x0;
+  float dy = fy - y0;
+  if (x0 < 0) {
+    x0 = 0;
+  }
+  if (y0 < 0) {
+    y0 = 0;
+  }
+  if (x1 < 0) {
+    x1 = 0;
+  }
+  if (y1 < 0) {
+    y1 = 0;
+  }
+  if (x0 >= w) {
+    x0 = w - 1;
+  }
+  if (y0 >= h) {
+    y0 = h - 1;
+  }
+  if (x1 >= w) {
+    x1 = w - 1;
+  }
+  if (y1 >= h) {
+    y1 = h - 1;
+  }
+  float v00 = gray[y0 * w + x0];
+  float v01 = gray[y0 * w + x1];
+  float v10 = gray[y1 * w + x0];
+  float v11 = gray[y1 * w + x1];
+  float v0 = v00 * (1.0f - dx) + v01 * dx;
+  float v1 = v10 * (1.0f - dx) + v11 * dx;
+  float v = v0 * (1.0f - dy) + v1 * dy;
+  if (v < 0.0f) {
+    v = 0.0f;
+  }
+  if (v > 255.0f) {
+    v = 255.0f;
+  }
+  return (int)(v + 0.5f);
+}
+
+/* Sample the QR module grid given a homography. If `gray_for_conf` is
+   non-NULL and `out_conf` is non-NULL, also writes a per-module ambiguity
+   buffer (1 = low-confidence module, 0 = high-confidence) of the same size
+   as the grid. The caller is responsible for free()ing both buffers.
+
+   Confidence is derived adaptively: after sampling all modules and their
+   per-module mean grayscale, compute the median grayscale of the modules
+   classified as light vs dark. A module is flagged ambiguous when its
+   mean gray sits a long way from BOTH expected values (signals an
+   occlusion patch whose color belongs to neither QR cluster) or when the
+   5 sub-sample votes were a near-tie (signals the patch boundary crosses
+   the module's sample area). */
+static uint8_t *sample_grid_homography_offset_mode(
+  uint8_t const *px,
+  uint8_t const *gray_for_conf,
+  int w,
+  int h,
+  const double H[9],
+  int qr_size,
+  double off_x,
+  double off_y,
+  int use_bilinear,
+  uint8_t **out_conf
+) {
+  int total = qr_size * qr_size;
+  uint8_t *grid = calloc(total, 1);
+  if (!grid) {
+    return NULL;
+  }
+  uint8_t *conf = NULL;
+  uint8_t *gmean_buf = NULL;
+  uint8_t *vote_tie = NULL;
+  if (out_conf && gray_for_conf) {
+    conf = calloc(total, 1);
+    gmean_buf = malloc(total);
+    vote_tie = calloc(total, 1);
+    if (!conf || !gmean_buf || !vote_tie) {
+      free(grid);
+      free(conf);
+      free(gmean_buf);
+      free(vote_tie);
+      return NULL;
+    }
+  }
+  static const double offs[5][2] = {
+    {0.5, 0.5},
+    {0.35, 0.35},
+    {0.65, 0.35},
+    {0.35, 0.65},
+    {0.65, 0.65},
+  };
+  for (int r = 0; r < qr_size; r++) {
+    for (int c = 0; c < qr_size; c++) {
+      int votes = 0;
+      int gray_sum = 0;
+      int gray_n = 0;
+      for (int o = 0; o < 5; o++) {
+        float fx, fy;
+        apply_homography(
+          H,
+          c + offs[o][0] + off_x,
+          r + offs[o][1] + off_y,
+          &fx,
+          &fy
+        );
+        int dark;
+        if (use_bilinear) {
+          dark = bilinear_dark(px, w, h, fx, fy);
+        }
+        else {
+          int ix = (int)(fx + 0.5f);
+          int iy = (int)(fy + 0.5f);
+          dark = is_dark(px, w, h, ix, iy);
+        }
+        if (dark) {
+          votes++;
+        }
+        if (gmean_buf) {
+          int g = sample_gray_bilinear(gray_for_conf, w, h, fx, fy);
+          if (g >= 0) {
+            gray_sum += g;
+            gray_n++;
+          }
+        }
+      }
+      grid[r * qr_size + c] = (votes >= 3) ? 1 : 0;
+      if (gmean_buf) {
+        int gmean = (gray_n > 0) ? (gray_sum / gray_n) : 128;
+        if (gmean < 0) {
+          gmean = 0;
+        }
+        if (gmean > 255) {
+          gmean = 255;
+        }
+        gmean_buf[r * qr_size + c] = (uint8_t)gmean;
+        if (vote_tie) {
+          vote_tie[r * qr_size + c] =
+            (uint8_t)((votes == 2 || votes == 3) ? 1 : 0);
+        }
+      }
+    }
+  }
+
+  if (conf && gmean_buf) {
+    /* Compute medians of dark / light cluster grays via histogram. */
+    int hist_dark[256] = {0};
+    int hist_light[256] = {0};
+    int n_dark = 0, n_light = 0;
+    for (int i = 0; i < total; i++) {
+      if (grid[i]) {
+        hist_dark[gmean_buf[i]]++;
+        n_dark++;
+      }
+      else {
+        hist_light[gmean_buf[i]]++;
+        n_light++;
+      }
+    }
+    int dark_med = 0, light_med = 255;
+    if (n_dark > 0) {
+      int target = n_dark / 2;
+      int acc = 0;
+      for (int v = 0; v < 256; v++) {
+        acc += hist_dark[v];
+        if (acc > target) {
+          dark_med = v;
+          break;
+        }
+      }
+    }
+    if (n_light > 0) {
+      int target = n_light / 2;
+      int acc = 0;
+      for (int v = 0; v < 256; v++) {
+        acc += hist_light[v];
+        if (acc > target) {
+          light_med = v;
+          break;
+        }
+      }
+    }
+    /* Tolerance: a chunky fraction of the cluster gap. A module is flagged
+       only when its mean gray is FAR from BOTH the dark and the light
+       cluster medians — that is the strong signal that the pixel belongs
+       to a foreign region (occlusion patch). We also keep a floor so the
+       rule still bites when the clusters are weakly separated. */
+    int gap = light_med - dark_med;
+    if (gap < 0) {
+      gap = -gap;
+    }
+    int tol = gap / 3;
+    if (tol < 35) {
+      tol = 35;
+    }
+    if (tol > 80) {
+      tol = 80;
+    }
+    for (int i = 0; i < total; i++) {
+      int g = gmean_buf[i];
+      int dd = g - dark_med;
+      if (dd < 0) {
+        dd = -dd;
+      }
+      int dl = g - light_med;
+      if (dl < 0) {
+        dl = -dl;
+      }
+      int ambig = 0;
+      if (dd > tol && dl > tol) {
+        ambig = 1;
+      }
+      conf[i] = (uint8_t)ambig;
+    }
+  }
+
+  free(gmean_buf);
+  free(vote_tie);
+  if (out_conf) {
+    *out_conf = conf;
+  }
+  return grid;
+}
+
+/* ---- Timing pattern check ----
+   The timing patterns on row 6 and column 6 alternate dark/light between the
+   three finder patterns. Starting at module (6, 8) = dark, (6, 9) = light,
+   (6, 10) = dark, … — dark whenever col or row is even. Real QRs have a
+   near-perfect match; a grid sampled from three incidental 1:1:3:1:1 runs in
+   noise produces essentially random bits here (~50% error). Returns the
+   fraction of timing modules whose value disagrees with the spec, 0.0 for a
+   perfect match and up to 1.0 for all wrong. */
+static float timing_error_rate(uint8_t const *grid, int qr_size) {
+  if (qr_size < 15) {
+    return 0.0f;
+  }
+  int errors = 0;
+  int total = 0;
+  for (int c = 8; c <= qr_size - 9; c++) {
+    int expected = ((c % 2) == 0) ? 1 : 0;
+    int actual = grid[6 * qr_size + c] ? 1 : 0;
+    if (actual != expected) {
+      errors++;
+    }
+    total++;
+  }
+  for (int r = 8; r <= qr_size - 9; r++) {
+    int expected = ((r % 2) == 0) ? 1 : 0;
+    int actual = grid[r * qr_size + 6] ? 1 : 0;
+    if (actual != expected) {
+      errors++;
+    }
+    total++;
+  }
+  if (total == 0) {
+    return 1.0f;
+  }
+  return (float)errors / (float)total;
+}
+
+/* ---- Finder pattern structural check ----
+   After sampling, each of the three 7x7 finder regions should match the
+   canonical dark/light pattern (outer ring dark, 1-module light inset, 3x3
+   dark core). Plus the 1-module separator (row/col between finder and data)
+   should be light. Incidental 1:1:3:1:1 runs in noise almost never project
+   into grids whose TL/TR/BL 7x7 windows all match the finder pattern.
+   Returns the fraction of the 48 checked cells (16 per finder) that
+   disagree — callers reject above ~0.10. */
+static const uint8_t FINDER_PATTERN[7][7] = {
+  {1, 1, 1, 1, 1, 1, 1},
+  {1, 0, 0, 0, 0, 0, 1},
+  {1, 0, 1, 1, 1, 0, 1},
+  {1, 0, 1, 1, 1, 0, 1},
+  {1, 0, 1, 1, 1, 0, 1},
+  {1, 0, 0, 0, 0, 0, 1},
+  {1, 1, 1, 1, 1, 1, 1},
+};
+
+static float finder_error_rate(uint8_t const *grid, int qr_size) {
+  if (qr_size < 21) {
+    return 1.0f;
+  }
+  int errors = 0;
+  int total = 0;
+  /* TL at (0,0); TR at (0, qr_size-7); BL at (qr_size-7, 0). */
+  const int origins[3][2] = {
+    {0, 0},
+    {0, qr_size - 7},
+    {qr_size - 7, 0},
+  };
+  for (int f = 0; f < 3; f++) {
+    int r0 = origins[f][0];
+    int c0 = origins[f][1];
+    for (int dr = 0; dr < 7; dr++) {
+      for (int dc = 0; dc < 7; dc++) {
+        int expected = FINDER_PATTERN[dr][dc];
+        int actual = grid[(r0 + dr) * qr_size + (c0 + dc)] ? 1 : 0;
+        if (actual != expected) {
+          errors++;
+        }
+        total++;
+      }
+    }
+  }
+  if (total == 0) {
+    return 1.0f;
+  }
+  return (float)errors / (float)total;
+}
+
+/* ---- Format information ---- */
+
+static const int FMT_COORDS1[15][2] = {
+  {8, 0},
+  {8, 1},
+  {8, 2},
+  {8, 3},
+  {8, 4},
+  {8, 5},
+  {8, 7},
+  {8, 8},
+  {7, 8},
+  {5, 8},
+  {4, 8},
+  {3, 8},
+  {2, 8},
+  {1, 8},
+  {0, 8},
+};
+
+static const uint32_t VALID_FMTS[32] = {
+  0x5412, 0x5125, 0x5E7C, 0x5B4B, 0x45F9, 0x40CE, 0x4F97, 0x4AA0,
+  0x77C4, 0x72F3, 0x7DAA, 0x789D, 0x662F, 0x6318, 0x6C41, 0x6976,
+  0x1689, 0x13BE, 0x1CE7, 0x19D0, 0x0762, 0x0255, 0x0D0C, 0x083B,
+  0x355F, 0x3068, 0x3F31, 0x3A06, 0x24B4, 0x2183, 0x2EDA, 0x2BED,
+};
+
+static int popcount_u(uint32_t v) {
+  int c = 0;
+  while (v) {
+    c += v & 1;
+    v >>= 1;
+  }
+  return c;
+}
+
+static int decode_format(
+  uint8_t const *grid,
+  int qr_size,
+  int *ec_level,
+  int *mask_pattern,
+  int *out_dist
+) {
+  uint32_t bits1 = 0;
+  for (int i = 0; i < 15; i++) {
+    int col = FMT_COORDS1[i][0], row = FMT_COORDS1[i][1];
+    if (row < qr_size && col < qr_size && grid[row * qr_size + col]) {
+      bits1 |= (1u << i);
+    }
+  }
+  uint32_t bits2 = 0;
+  for (int i = 0; i < 7; i++) {
+    int row = qr_size - 1 - i;
+    if (grid[row * qr_size + 8]) {
+      bits2 |= (1u << i);
+    }
+  }
+  for (int i = 0; i < 8; i++) {
+    int col = qr_size - 8 + i;
+    if (grid[8 * qr_size + col]) {
+      bits2 |= (1u << (7 + i));
+    }
+  }
+
+  int best_d = 16, best_f = -1;
+  for (int copy = 0; copy < 2; copy++) {
+    uint32_t bits = (copy == 0) ? bits1 : bits2;
+    for (int f = 0; f < 32; f++) {
+      int d = popcount_u(bits ^ VALID_FMTS[f]);
+      if (d < best_d) {
+        best_d = d;
+        best_f = f;
+      }
+    }
+  }
+  if (best_f < 0 || best_d > 3) {
+    return -1;
+  }
+
+  int ec_ind = best_f >> 3;
+  *mask_pattern = best_f & 7;
+  static const int ec_map[4] = {1, 0, 3, 2};
+  *ec_level = ec_map[ec_ind];
+  if (out_dist) {
+    *out_dist = best_d;
+  }
+  return 0;
+}
+
+/* ---- Masking ---- */
+
+static int apply_mask(int mask, int row, int col) {
+  switch (mask) {
+  case 0:
+    return (row + col) % 2 == 0;
+  case 1:
+    return row % 2 == 0;
+  case 2:
+    return col % 3 == 0;
+  case 3:
+    return (row + col) % 3 == 0;
+  case 4:
+    return (row / 2 + col / 3) % 2 == 0;
+  case 5:
+    return (row * col) % 2 + (row * col) % 3 == 0;
+  case 6:
+    return ((row * col) % 2 + (row * col) % 3) % 2 == 0;
+  case 7:
+    return ((row + col) % 2 + (row * col) % 3) % 2 == 0;
+  default:
+    return 0;
+  }
+}
+
+/* ---- Function pattern check ---- */
+
+static const int ALIGN_POS[41][8] = {
+  {0},
+  {0},
+  {6, 18, 0},
+  {6, 22, 0},
+  {6, 26, 0},
+  {6, 30, 0},
+  {6, 34, 0},
+  {6, 22, 38, 0},
+  {6, 24, 42, 0},
+  {6, 26, 46, 0},
+  {6, 28, 50, 0},
+  {6, 30, 54, 0},
+  {6, 32, 58, 0},
+  {6, 34, 62, 0},
+  {6, 26, 46, 66, 0},
+  {6, 26, 48, 70, 0},
+  {6, 26, 50, 74, 0},
+  {6, 30, 54, 78, 0},
+  {6, 30, 56, 82, 0},
+  {6, 30, 58, 86, 0},
+  {6, 34, 62, 90, 0},
+  {6, 28, 50, 72, 94, 0},
+  {6, 26, 50, 74, 98, 0},
+  {6, 30, 54, 78, 102, 0},
+  {6, 28, 54, 80, 106, 0},
+  {6, 32, 58, 84, 110, 0},
+  {6, 30, 58, 86, 114, 0},
+  {6, 34, 62, 90, 118, 0},
+  {6, 26, 50, 74, 98, 122, 0},
+  {6, 30, 54, 78, 102, 126, 0},
+  {6, 26, 52, 78, 104, 130, 0},
+  {6, 30, 56, 82, 108, 134, 0},
+  {6, 34, 60, 86, 112, 138, 0},
+  {6, 30, 58, 86, 114, 142, 0},
+  {6, 34, 62, 90, 118, 146, 0},
+  {6, 30, 54, 78, 102, 126, 150, 0},
+  {6, 24, 50, 76, 102, 128, 154, 0},
+  {6, 28, 54, 80, 106, 132, 158, 0},
+  {6, 32, 58, 84, 110, 136, 162, 0},
+  {6, 26, 54, 82, 110, 138, 166, 0},
+  {6, 30, 58, 86, 114, 142, 170, 0},
+};
+
+static int is_function(int row, int col, int sz, int ver) {
+  if (row <= 8 && col <= 8) {
+    return 1;
+  }
+  if (row <= 8 && col >= sz - 8) {
+    return 1;
+  }
+  if (row >= sz - 8 && col <= 8) {
+    return 1;
+  }
+  if (row == 6 || col == 6) {
+    return 1;
+  }
+  if (ver >= 7) {
+    if (col >= sz - 11 && col <= sz - 9 && row <= 5) {
+      return 1;
+    }
+    if (row >= sz - 11 && row <= sz - 9 && col <= 5) {
+      return 1;
+    }
+  }
+  if (ver >= 2 && ver <= 40) {
+    const int *pos = ALIGN_POS[ver];
+    int np = 0;
+    while (pos[np] != 0 && np < 7) {
+      np++;
+    }
+    for (int i = 0; i < np; i++) {
+      for (int j = 0; j < np; j++) {
+        int ay = pos[i], ax = pos[j];
+        if (ay <= 8 && ax <= 8) {
+          continue;
+        }
+        if (ay <= 8 && ax >= sz - 8) {
+          continue;
+        }
+        if (ay >= sz - 8 && ax <= 8) {
+          continue;
+        }
+        if (abs(row - ay) <= 2 && abs(col - ax) <= 2) {
+          return 1;
+        }
+      }
+    }
+  }
+  return 0;
+}
+
+/* ---- Data extraction in zigzag order ---- */
+
+static int extract_bits(
+  uint8_t const *grid,
+  uint8_t const *conf,
+  int sz,
+  int ver,
+  int mask,
+  uint8_t *bits,
+  uint8_t *bit_conf,
+  int max_bits
+) {
+  int n = 0, up = 1;
+  for (int right = sz - 1; right >= 1; right -= 2) {
+    if (right == 6) {
+      right = 5;
+    }
+    for (int i = 0; i < sz; i++) {
+      int row = up ? (sz - 1 - i) : i;
+      for (int c = 0; c < 2; c++) {
+        int col = right - c;
+        if (col < 0) {
+          continue;
+        }
+        if (!is_function(row, col, sz, ver) && n < max_bits) {
+          int val = grid[row * sz + col];
+          if (apply_mask(mask, row, col)) {
+            val ^= 1;
+          }
+          bits[n] = (uint8_t)val;
+          if (bit_conf) {
+            bit_conf[n] = conf ? conf[row * sz + col] : 0;
+          }
+          n++;
+        }
+      }
+    }
+    up = !up;
+  }
+  return n;
+}
+
+/* ---- Bits to bytes ---- */
+
+static int bits_to_bytes(
+  uint8_t const *bits,
+  uint8_t const *bit_conf,
+  int nbits,
+  uint8_t *bytes,
+  uint8_t *byte_ambig
+) {
+  int nb = nbits / 8;
+  for (int i = 0; i < nb; i++) {
+    uint8_t b = 0;
+    int ambig = 0;
+    for (int j = 0; j < 8; j++) {
+      b = (b << 1) | bits[i * 8 + j];
+      if (bit_conf && bit_conf[i * 8 + j]) {
+        ambig = 1;
+      }
+    }
+    bytes[i] = b;
+    if (byte_ambig) {
+      byte_ambig[i] = (uint8_t)ambig;
+    }
+  }
+  return nb;
+}
+
+/* ---- Read N bits from byte array ---- */
+
+static int read_bits(uint8_t const *data, int len, int *pos, int n) {
+  int val = 0;
+  for (int i = 0; i < n; i++) {
+    int bi = *pos + i;
+    int byte_idx = bi / 8, bit_idx = 7 - (bi % 8);
+    if (byte_idx < len) {
+      val = (val << 1) | ((data[byte_idx] >> bit_idx) & 1);
+    }
+    else {
+      val <<= 1;
+    }
+  }
+  *pos += n;
+  return val;
+}
+
+/* ---- EC table ---- */
+
+typedef struct {
+  int total_cw, ec_cw_per_block;
+  int g1_blocks, g1_data_cw;
+  int g2_blocks, g2_data_cw;
+} ECInfo;
+
+static const ECInfo EC_TABLE[40][4] = {
+  /* L                            M                         Q H */
+  /* v1  */ {
+    {26, 7, 1, 19, 0, 0},
+    {26, 10, 1, 16, 0, 0},
+    {26, 13, 1, 13, 0, 0},
+    {26, 17, 1, 9, 0, 0}
+  },
+  /* v2  */
+  {{44, 10, 1, 34, 0, 0},
+   {44, 16, 1, 28, 0, 0},
+   {44, 22, 1, 22, 0, 0},
+   {44, 28, 1, 16, 0, 0}},
+  /* v3  */
+  {{70, 15, 1, 55, 0, 0},
+   {70, 26, 1, 44, 0, 0},
+   {70, 18, 2, 17, 0, 0},
+   {70, 22, 2, 13, 0, 0}},
+  /* v4  */
+  {{100, 20, 1, 80, 0, 0},
+   {100, 18, 2, 32, 0, 0},
+   {100, 26, 2, 24, 0, 0},
+   {100, 16, 4, 9, 0, 0}},
+  /* v5  */
+  {{134, 26, 1, 108, 0, 0},
+   {134, 24, 2, 43, 0, 0},
+   {134, 18, 2, 15, 2, 16},
+   {134, 22, 2, 11, 2, 12}},
+  /* v6  */
+  {{172, 18, 2, 68, 0, 0},
+   {172, 16, 4, 27, 0, 0},
+   {172, 24, 4, 19, 0, 0},
+   {172, 28, 4, 15, 0, 0}},
+  /* v7  */
+  {{196, 20, 2, 78, 0, 0},
+   {196, 18, 4, 31, 0, 0},
+   {196, 18, 2, 14, 4, 15},
+   {196, 26, 4, 13, 1, 14}},
+  /* v8  */
+  {{242, 24, 2, 97, 0, 0},
+   {242, 22, 2, 38, 2, 39},
+   {242, 22, 4, 18, 2, 19},
+   {242, 26, 4, 14, 2, 15}},
+  /* v9  */
+  {{292, 30, 2, 116, 0, 0},
+   {292, 22, 3, 36, 2, 37},
+   {292, 20, 4, 16, 4, 17},
+   {292, 24, 4, 12, 4, 13}},
+  /* v10 */
+  {{346, 18, 2, 68, 2, 69},
+   {346, 26, 4, 43, 1, 44},
+   {346, 24, 6, 19, 2, 20},
+   {346, 28, 6, 15, 2, 16}},
+  /* v11 */
+  {{404, 20, 4, 81, 0, 0},
+   {404, 30, 1, 50, 4, 51},
+   {404, 28, 4, 22, 4, 23},
+   {404, 24, 3, 12, 8, 13}},
+  /* v12 */
+  {{466, 24, 2, 92, 2, 93},
+   {466, 22, 6, 36, 2, 37},
+   {466, 26, 4, 20, 6, 21},
+   {466, 28, 7, 14, 4, 15}},
+  /* v13 */
+  {{532, 26, 4, 107, 0, 0},
+   {532, 22, 8, 37, 1, 38},
+   {532, 24, 8, 20, 4, 21},
+   {532, 22, 12, 11, 4, 12}},
+  /* v14 */
+  {{581, 30, 3, 115, 1, 116},
+   {581, 24, 4, 40, 5, 41},
+   {581, 20, 11, 16, 5, 17},
+   {581, 24, 11, 12, 5, 13}},
+  /* v15 */
+  {{655, 22, 5, 87, 1, 88},
+   {655, 24, 5, 41, 5, 42},
+   {655, 30, 5, 24, 7, 25},
+   {655, 24, 11, 12, 7, 13}},
+  /* v16 */
+  {{733, 24, 5, 98, 1, 99},
+   {733, 28, 7, 45, 3, 46},
+   {733, 24, 15, 19, 2, 20},
+   {733, 30, 3, 15, 13, 16}},
+  /* v17 */
+  {{815, 28, 1, 107, 5, 108},
+   {815, 28, 10, 46, 1, 47},
+   {815, 28, 1, 22, 15, 23},
+   {815, 28, 2, 14, 17, 15}},
+  /* v18 */
+  {{901, 30, 5, 120, 1, 121},
+   {901, 26, 9, 43, 4, 44},
+   {901, 28, 17, 22, 1, 23},
+   {901, 28, 2, 14, 19, 15}},
+  /* v19 */
+  {{991, 28, 3, 113, 4, 114},
+   {991, 26, 3, 44, 11, 45},
+   {991, 26, 17, 21, 4, 22},
+   {991, 26, 9, 13, 16, 14}},
+  /* v20 */
+  {{1085, 28, 3, 107, 5, 108},
+   {1085, 26, 3, 41, 13, 42},
+   {1085, 30, 15, 24, 5, 25},
+   {1085, 28, 15, 15, 10, 16}},
+  /* v21 */
+  {{1156, 28, 4, 116, 4, 117},
+   {1156, 26, 17, 42, 0, 0},
+   {1156, 28, 17, 22, 6, 23},
+   {1156, 30, 19, 16, 6, 17}},
+  /* v22 */
+  {{1258, 28, 2, 111, 7, 112},
+   {1258, 28, 17, 46, 0, 0},
+   {1258, 30, 7, 24, 16, 25},
+   {1258, 24, 34, 13, 0, 0}},
+  /* v23 */
+  {{1364, 30, 4, 121, 5, 122},
+   {1364, 28, 4, 47, 14, 48},
+   {1364, 30, 11, 24, 14, 25},
+   {1364, 30, 16, 15, 14, 16}},
+  /* v24 */
+  {{1474, 30, 6, 117, 4, 118},
+   {1474, 28, 6, 45, 14, 46},
+   {1474, 30, 11, 24, 16, 25},
+   {1474, 30, 30, 16, 2, 17}},
+  /* v25 */
+  {{1588, 26, 8, 106, 4, 107},
+   {1588, 28, 8, 47, 13, 48},
+   {1588, 30, 7, 24, 22, 25},
+   {1588, 30, 22, 15, 13, 16}},
+  /* v26 */
+  {{1706, 28, 10, 114, 2, 115},
+   {1706, 28, 19, 46, 4, 47},
+   {1706, 28, 28, 22, 6, 23},
+   {1706, 30, 33, 16, 4, 17}},
+  /* v27 */
+  {{1828, 30, 8, 122, 4, 123},
+   {1828, 28, 22, 45, 3, 46},
+   {1828, 30, 8, 23, 26, 24},
+   {1828, 30, 12, 15, 28, 16}},
+  /* v28 */
+  {{1921, 30, 3, 117, 10, 118},
+   {1921, 28, 3, 45, 23, 46},
+   {1921, 30, 4, 24, 31, 25},
+   {1921, 30, 11, 15, 31, 16}},
+  /* v29 */
+  {{2051, 30, 7, 116, 7, 117},
+   {2051, 28, 21, 45, 7, 46},
+   {2051, 30, 1, 23, 37, 24},
+   {2051, 30, 19, 15, 26, 16}},
+  /* v30 */
+  {{2185, 30, 5, 115, 10, 116},
+   {2185, 28, 19, 47, 10, 48},
+   {2185, 30, 15, 24, 25, 25},
+   {2185, 30, 23, 15, 25, 16}},
+  /* v31 */
+  {{2323, 30, 13, 115, 3, 116},
+   {2323, 28, 2, 46, 29, 47},
+   {2323, 30, 42, 24, 1, 25},
+   {2323, 30, 23, 15, 28, 16}},
+  /* v32 */
+  {{2465, 30, 17, 115, 0, 0},
+   {2465, 28, 10, 46, 23, 47},
+   {2465, 30, 10, 24, 35, 25},
+   {2465, 30, 19, 15, 35, 16}},
+  /* v33 */
+  {{2611, 30, 17, 115, 1, 116},
+   {2611, 28, 14, 46, 21, 47},
+   {2611, 30, 29, 24, 19, 25},
+   {2611, 30, 11, 15, 46, 16}},
+  /* v34 */
+  {{2761, 30, 13, 115, 6, 116},
+   {2761, 28, 14, 46, 23, 47},
+   {2761, 30, 44, 24, 7, 25},
+   {2761, 30, 59, 16, 1, 17}},
+  /* v35 */
+  {{2876, 30, 12, 121, 7, 122},
+   {2876, 28, 12, 47, 26, 48},
+   {2876, 30, 39, 24, 14, 25},
+   {2876, 30, 22, 15, 41, 16}},
+  /* v36 */
+  {{3034, 30, 6, 121, 14, 122},
+   {3034, 28, 6, 47, 34, 48},
+   {3034, 30, 46, 24, 10, 25},
+   {3034, 30, 2, 15, 64, 16}},
+  /* v37 */
+  {{3196, 30, 17, 122, 4, 123},
+   {3196, 28, 29, 46, 14, 47},
+   {3196, 30, 49, 24, 10, 25},
+   {3196, 30, 24, 15, 46, 16}},
+  /* v38 */
+  {{3362, 30, 4, 122, 18, 123},
+   {3362, 28, 13, 46, 32, 47},
+   {3362, 30, 48, 24, 14, 25},
+   {3362, 30, 42, 15, 32, 16}},
+  /* v39 */
+  {{3532, 30, 20, 117, 4, 118},
+   {3532, 28, 40, 47, 7, 48},
+   {3532, 30, 43, 24, 22, 25},
+   {3532, 30, 10, 15, 67, 16}},
+  /* v40 */
+  {{3706, 30, 19, 118, 6, 119},
+   {3706, 28, 18, 47, 31, 48},
+   {3706, 30, 34, 24, 34, 25},
+   {3706, 30, 20, 15, 61, 16}},
+};
+
+/* ---- Reed-Solomon error correction over GF(256), primitive poly 0x11d ---- */
+
+static uint8_t gf_exp[512];
+static uint8_t gf_log[256];
+static int gf_initialized = 0;
+
+static void gf_init(void) {
+  if (gf_initialized) {
+    return;
+  }
+  int x = 1;
+  for (int i = 0; i < 255; i++) {
+    gf_exp[i] = (uint8_t)x;
+    gf_log[x] = (uint8_t)i;
+    x <<= 1;
+    if (x & 0x100) {
+      x ^= 0x11d;
+    }
+  }
+  for (int i = 255; i < 512; i++) {
+    gf_exp[i] = gf_exp[i - 255];
+  }
+  gf_initialized = 1;
+}
+
+static uint8_t gf_mul(uint8_t a, uint8_t b) {
+  if (a == 0 || b == 0) {
+    return 0;
+  }
+  return gf_exp[gf_log[a] + gf_log[b]];
+}
+
+static uint8_t gf_inv(uint8_t a) { return gf_exp[255 - gf_log[a]]; }
+
+/* Decode an RS block of nk data codewords followed by nroots EC codewords,
+   in standard polynomial order (msg[0] is highest-degree coefficient).
+   Optional erasure positions (eras_pos[k] is the msg index of an erasure)
+   double the per-block correction budget: 2*errors + erasures <= nroots.
+   Pass eras_pos=NULL/n_eras=0 for plain errors-only decoding (corrects up
+   to nroots/2 errors). Returns number of corrections (errors+erasures) made
+   in place, or -1 if the block is uncorrectable. */
+static int rs_decode_block_eras(
+  uint8_t *msg,
+  int nk,
+  int nroots,
+  const int *eras_pos,
+  int n_eras
+) {
+  gf_init();
+  int n = nk + nroots;
+  if (nroots <= 0 || nroots > 64 || nk <= 0 || n > 300) {
+    return -1;
+  }
+  if (n_eras < 0 || n_eras > nroots) {
+    return -1;
+  }
+
+  /* Syndromes: S[i] = msg(α^i) for i in [0, nroots). */
+  uint8_t syn[64];
+  int nonzero = 0;
+  for (int i = 0; i < nroots; i++) {
+    uint8_t x = gf_exp[i];
+    uint8_t s = 0;
+    for (int j = 0; j < n; j++) {
+      s = gf_mul(s, x) ^ msg[j];
+    }
+    syn[i] = s;
+    if (s) {
+      nonzero = 1;
+    }
+  }
+  if (!nonzero) {
+    return 0;
+  }
+
+  /* Erasure locator Λ_E(x) = ∏ (1 - α^{n-1-eras_pos[k]} x), ascending degree.
+     Roots at x = α^{-(n-1-eras_pos[k])}, matching the same x = α^{-i}
+     convention used by the Chien search below. */
+  uint8_t lambda_e[65] = {0};
+  lambda_e[0] = 1;
+  int le_deg = 0;
+  uint8_t seen_eras[300] = {0};
+  for (int k = 0; k < n_eras; k++) {
+    int e = eras_pos[k];
+    if (e < 0 || e >= n) {
+      return -1;
+    }
+    if (seen_eras[e]) {
+      continue; /* dedupe duplicate erasure positions */
+    }
+    seen_eras[e] = 1;
+    int p = (n - 1 - e) % 255;
+    if (p < 0) {
+      p += 255;
+    }
+    uint8_t a = gf_exp[p];
+    if (le_deg + 1 >= 65) {
+      return -1;
+    }
+    le_deg++;
+    for (int i = le_deg; i > 0; i--) {
+      lambda_e[i] ^= gf_mul(a, lambda_e[i - 1]);
+    }
+  }
+
+  /* Forney syndromes T(x) = (Λ_E(x) * S(x)) mod x^nroots. */
+  uint8_t Tsyn[64] = {0};
+  for (int i = 0; i < nroots; i++) {
+    uint8_t s = 0;
+    for (int j = 0; j <= le_deg && j <= i; j++) {
+      s ^= gf_mul(lambda_e[j], syn[i - j]);
+    }
+    Tsyn[i] = s;
+  }
+
+  /* Berlekamp-Massey on the trailing (nroots - le_deg) Forney syndromes to
+     find the error locator Σ(x) for unknown-position errors. The first
+     le_deg Forney coefficients are absorbed by the erasure locator. */
+  uint8_t C[65] = {0};
+  uint8_t B[65] = {0};
+  C[0] = 1;
+  B[0] = 1;
+  int L = 0;
+  int m = 1;
+  uint8_t b_last = 1;
+  int n_iter = nroots - le_deg;
+
+  for (int k = 0; k < n_iter; k++) {
+    int kk = k + le_deg;
+    uint8_t delta = Tsyn[kk];
+    for (int i = 1; i <= L; i++) {
+      if (kk - i >= 0) {
+        delta ^= gf_mul(C[i], Tsyn[kk - i]);
+      }
+    }
+    if (delta != 0) {
+      uint8_t coef = gf_mul(delta, gf_inv(b_last));
+      uint8_t Tc[65];
+      memcpy(Tc, C, sizeof(Tc));
+      for (int i = 0; i + m < 65; i++) {
+        C[i + m] ^= gf_mul(coef, B[i]);
+      }
+      if (2 * L <= k) {
+        L = k + 1 - L;
+        memcpy(B, Tc, sizeof(B));
+        b_last = delta;
+        m = 1;
+      }
+      else {
+        m++;
+      }
+    }
+    else {
+      m++;
+    }
+  }
+
+  if (2 * L + le_deg > nroots) {
+    return -1;
+  }
+
+  /* Combined locator Ψ(x) = Λ_E(x) * Σ(x). Coefficients in [0..130). */
+  uint8_t Psi[130] = {0};
+  for (int i = 0; i <= le_deg; i++) {
+    if (!lambda_e[i]) {
+      continue;
+    }
+    for (int j = 0; j <= L; j++) {
+      if (!C[j]) {
+        continue;
+      }
+      Psi[i + j] ^= gf_mul(lambda_e[i], C[j]);
+    }
+  }
+  int psi_deg = le_deg + L;
+  if (psi_deg <= 0 || psi_deg > nroots) {
+    return -1;
+  }
+
+  /* Chien search: combined locator roots are at x = α^{-i}. */
+  int err_pos[64];
+  int nerr = 0;
+  for (int i = 0; i < n; i++) {
+    int xi = (255 - (i % 255)) % 255;
+    uint8_t v = 0;
+    for (int j = 0; j <= psi_deg; j++) {
+      if (Psi[j]) {
+        int e = (gf_log[Psi[j]] + xi * j) % 255;
+        v ^= gf_exp[e];
+      }
+    }
+    if (v == 0) {
+      if (nerr >= 64) {
+        return -1;
+      }
+      err_pos[nerr++] = i;
+    }
+  }
+  if (nerr != psi_deg) {
+    return -1;
+  }
+
+  /* Forney: Ω(x) = (S(x) * Ψ(x)) mod x^nroots, then magnitude at position i
+     is Ω(α^{-i}) / Ψ'(α^{-i}). Ψ has degree up to nroots, so omega and Ψ'
+     buffers need to span that range. */
+  uint8_t omega[130] = {0};
+  for (int i = 0; i < nroots; i++) {
+    for (int j = 0; j <= psi_deg && i + j < 130; j++) {
+      if (i + j < nroots) {
+        omega[i + j] ^= gf_mul(syn[i], Psi[j]);
+      }
+    }
+  }
+
+  uint8_t Psip[130] = {0};
+  for (int i = 1; i <= psi_deg; i += 2) {
+    Psip[i - 1] = Psi[i];
+  }
+
+  for (int k = 0; k < nerr; k++) {
+    int i = err_pos[k];
+    int xi = (255 - (i % 255)) % 255;
+
+    uint8_t o = 0;
+    for (int j = 0; j < nroots; j++) {
+      if (omega[j]) {
+        int e = (gf_log[omega[j]] + xi * j) % 255;
+        o ^= gf_exp[e];
+      }
+    }
+
+    uint8_t cp = 0;
+    for (int j = 0; j < 130; j++) {
+      if (Psip[j]) {
+        int e = (gf_log[Psip[j]] + xi * j) % 255;
+        cp ^= gf_exp[e];
+      }
+    }
+
+    if (cp == 0) {
+      return -1;
+    }
+    /* Forney for syndromes starting at b=0: e_j = X_j * Ω(X_j^{-1}) /
+       Ψ'(X_j^{-1}). The X_j factor is essential — without it the recovered
+       magnitude is e_j / X_j, the verify check fails, and RS decoding silently
+       bails out on every block with at least one real error. */
+    int xpow = i % 255;
+    uint8_t correction = gf_mul(o, gf_inv(cp));
+    correction = gf_mul(correction, gf_exp[xpow]);
+    msg[n - 1 - i] ^= correction;
+  }
+
+  /* Verify: all syndromes zero now. */
+  for (int i = 0; i < nroots; i++) {
+    uint8_t x = gf_exp[i];
+    uint8_t s = 0;
+    for (int j = 0; j < n; j++) {
+      s = gf_mul(s, x) ^ msg[j];
+    }
+    if (s != 0) {
+      return -1;
+    }
+  }
+
+  return nerr;
+}
+
+static int rs_decode_block(uint8_t *msg, int nk, int nroots) {
+  return rs_decode_block_eras(msg, nk, nroots, NULL, 0);
+}
+
+/* ---- De-interleave codewords + Reed-Solomon error correction ----
+   Splits the raw interleaved codeword stream into per-block [data | EC]
+   buffers, runs RS decode on each block, and writes the corrected data
+   codewords to `out`. Returns the total number of data codewords, or 0 on
+   structural failure. Individual block RS failures are tolerated: the
+   (uncorrected) bytes are still written so the payload decoder can try.
+   If out_rs_cost is non-NULL, writes the total RS error-correction cost:
+   sum of bits corrected across all blocks, plus a heavy penalty per
+   uncorrectable block. Lower values mean a more confident decode. */
+static int deinterleave(
+  uint8_t const *raw,
+  uint8_t const *raw_ambig,
+  int raw_len,
+  const ECInfo *info,
+  uint8_t *out,
+  int *out_rs_cost
+) {
+  int total_blocks = info->g1_blocks + info->g2_blocks;
+  if (total_blocks == 0 || total_blocks > 100) {
+    return 0;
+  }
+  int ec_cw = info->ec_cw_per_block;
+  int total_data =
+    info->g1_blocks * info->g1_data_cw + info->g2_blocks * info->g2_data_cw;
+  int total_needed = total_data + total_blocks * ec_cw;
+  if (total_needed > raw_len) {
+    /* Not enough bytes to run RS. Fall back to copying available data only. */
+    int n = total_data < raw_len ? total_data : raw_len;
+    memcpy(out, raw, n);
+    return n;
+  }
+
+  uint8_t **blocks = calloc(total_blocks, sizeof(uint8_t *));
+  uint8_t **block_ambig = NULL;
+  int *dlen = calloc(total_blocks, sizeof(int));
+  if (raw_ambig) {
+    block_ambig = calloc(total_blocks, sizeof(uint8_t *));
+  }
+  if (!blocks || !dlen || (raw_ambig && !block_ambig)) {
+    free(blocks);
+    free(dlen);
+    free(block_ambig);
+    return 0;
+  }
+  int alloc_fail = 0;
+  for (int b = 0; b < total_blocks; b++) {
+    dlen[b] = (b < info->g1_blocks) ? info->g1_data_cw : info->g2_data_cw;
+    blocks[b] = calloc(dlen[b] + ec_cw, 1);
+    if (block_ambig) {
+      block_ambig[b] = calloc(dlen[b] + ec_cw, 1);
+      if (!block_ambig[b]) {
+        alloc_fail = 1;
+      }
+    }
+    if (!blocks[b]) {
+      alloc_fail = 1;
+    }
+  }
+  if (alloc_fail) {
+    for (int b = 0; b < total_blocks; b++) {
+      free(blocks[b]);
+      if (block_ambig) {
+        free(block_ambig[b]);
+      }
+    }
+    free(blocks);
+    free(block_ambig);
+    free(dlen);
+    return 0;
+  }
+
+  int max_data_cw = (info->g2_blocks > 0) ? info->g2_data_cw : info->g1_data_cw;
+  int pos = 0;
+
+  /* Data codewords are interleaved round-robin by position. Group-1 blocks
+     run out first (they're shorter), so later rounds only fill group-2. */
+  for (int i = 0; i < max_data_cw; i++) {
+    for (int b = 0; b < total_blocks && pos < raw_len; b++) {
+      if (i < dlen[b]) {
+        blocks[b][i] = raw[pos];
+        if (block_ambig) {
+          block_ambig[b][i] = raw_ambig[pos];
+        }
+        pos++;
+      }
+    }
+  }
+  /* EC codewords: all blocks have the same EC length, so clean round-robin. */
+  for (int i = 0; i < ec_cw; i++) {
+    for (int b = 0; b < total_blocks && pos < raw_len; b++) {
+      blocks[b][dlen[b] + i] = raw[pos];
+      if (block_ambig) {
+        block_ambig[b][dlen[b] + i] = raw_ambig[pos];
+      }
+      pos++;
+    }
+  }
+
+  /* Snapshot the original block bytes BEFORE rs_decode_block mutates them
+     in place. Used to retry with erasures (and to detect when erasure
+     decoding flips bytes the errors-only path "corrected" away). */
+  uint8_t **orig = NULL;
+  if (block_ambig) {
+    orig = calloc(total_blocks, sizeof(uint8_t *));
+    if (orig) {
+      for (int b = 0; b < total_blocks; b++) {
+        int blk_n = dlen[b] + ec_cw;
+        orig[b] = malloc(blk_n);
+        if (orig[b]) {
+          memcpy(orig[b], blocks[b], blk_n);
+        }
+      }
+    }
+  }
+
+  int rs_cost = 0;
+  for (int b = 0; b < total_blocks; b++) {
+    int blk_n = dlen[b] + ec_cw;
+    int corr = rs_decode_block(blocks[b], dlen[b], ec_cw);
+
+    if (block_ambig && orig && orig[b]) {
+      /* Collect erasure candidate positions for this block. */
+      int eras_pos[64];
+      int n_eras = 0;
+      for (int k = 0; k < blk_n && n_eras < ec_cw && n_eras < 64; k++) {
+        if (block_ambig[b][k]) {
+          eras_pos[n_eras++] = k;
+        }
+      }
+
+      if (n_eras > 0) {
+        uint8_t *retry = malloc(blk_n);
+        if (retry) {
+          memcpy(retry, orig[b], blk_n);
+          int corr2 =
+            rs_decode_block_eras(retry, dlen[b], ec_cw, eras_pos, n_eras);
+          if (corr2 >= 0) {
+            int prefer_erasure = 0;
+            if (corr < 0) {
+              /* Errors-only failed; erasure version is our only option. */
+              prefer_erasure = 1;
+            }
+            else if (memcmp(retry, blocks[b], blk_n) != 0) {
+              /* Errors-only "succeeded" but with a different answer than
+                 the erasure-aware decode. The erasure decode had MORE
+                 constraints (specific byte positions known to be unreliable)
+                 so it's more likely to be the true codeword when our
+                 erasure candidates overlap the real errors. The risk: if
+                 most candidates were wrong, this flips a correct decode to
+                 a wrong one — the upper-layer payload validator and
+                 fmt_dist scoring is what saves us when that happens. */
+              prefer_erasure = 1;
+            }
+            if (prefer_erasure) {
+              memcpy(blocks[b], retry, blk_n);
+              corr = corr2;
+              rs_cost += corr2 + n_eras * 2 + 200;
+              free(retry);
+              continue;
+            }
+          }
+          free(retry);
+        }
+      }
+    }
+
+    if (corr < 0) {
+      /* Uncorrectable block: every EC symbol effectively spent without
+         success. Penalise heavily so this decode loses ties to one whose
+         blocks all decoded cleanly. */
+      rs_cost += ec_cw * 8 + 1000;
+    }
+    else {
+      rs_cost += corr;
+    }
+  }
+  if (orig) {
+    for (int b = 0; b < total_blocks; b++) {
+      free(orig[b]);
+    }
+    free(orig);
+  }
+  if (out_rs_cost) {
+    *out_rs_cost = rs_cost;
+  }
+
+  int out_pos = 0;
+  for (int b = 0; b < total_blocks; b++) {
+    memcpy(out + out_pos, blocks[b], dlen[b]);
+    out_pos += dlen[b];
+    free(blocks[b]);
+    if (block_ambig) {
+      free(block_ambig[b]);
+    }
+  }
+  free(blocks);
+  free(block_ambig);
+  free(dlen);
+  return out_pos;
+}
+
+/* ---- Payload decoding ---- */
+
+static char *decode_payload(uint8_t const *data, int len, int ver) {
+  if (len < 1) {
+    return NULL;
+  }
+  int pos = 0;
+  int mode = read_bits(data, len, &pos, 4);
+  if (mode == 0) {
+    return NULL;
+  }
+
+  /* ECI (Extended Channel Interpretation): consume the assignment number
+     bytes and continue with the next mode indicator. URL QRs commonly
+     begin with ECI 26 (UTF-8) or ECI 3 (ISO-8859-1) followed by byte
+     mode. We don't do any character-set re-encoding — all the ECIs we
+     care about produce payloads that are already valid UTF-8 or ASCII. */
+  if (mode == 7) {
+    int first = read_bits(data, len, &pos, 1);
+    if (first < 0) {
+      return NULL;
+    }
+    if (first == 0) {
+      /* 1-byte ECI (7 more bits, total 8) */
+      if (read_bits(data, len, &pos, 7) < 0) {
+        return NULL;
+      }
+    }
+    else {
+      int second = read_bits(data, len, &pos, 1);
+      if (second < 0) {
+        return NULL;
+      }
+      if (second == 0) {
+        /* 2-byte ECI (14 more bits, total 16) */
+        if (read_bits(data, len, &pos, 14) < 0) {
+          return NULL;
+        }
+      }
+      else {
+        /* 3-byte ECI (21 more bits, total 24) */
+        if (read_bits(data, len, &pos, 21) < 0) {
+          return NULL;
+        }
+      }
+    }
+    mode = read_bits(data, len, &pos, 4);
+    if (mode == 0) {
+      return NULL;
+    }
+  }
+
+  int cc_bits;
+  if (mode == 1) {
+    cc_bits = (ver < 10) ? 10 : (ver < 27) ? 12 : 14;
+  }
+  else if (mode == 2) {
+    cc_bits = (ver < 10) ? 9 : (ver < 27) ? 11 : 13;
+  }
+  else if (mode == 4) {
+    cc_bits = (ver < 10) ? 8 : 16;
+  }
+  else if (mode == 8) {
+    cc_bits = (ver < 10) ? 8 : (ver < 27) ? 10 : 12;
+  }
+  else {
+    return NULL;
+  }
+
+  int count = read_bits(data, len, &pos, cc_bits);
+  /* Reject impossible counts: must be positive and must fit in the data bits
+     remaining after all header bits consumed so far (mode + optional ECI
+     header + count). A bit-flip in the high bit of the count field can
+     otherwise produce massively-inflated counts that consume padding bytes
+     as fake content (numeric mode is especially prone — corrupted padding
+     bytes decode as long runs of '0' digits). */
+  int avail_bits = len * 8 - pos;
+  if (avail_bits < 0) {
+    free(NULL);
+    return NULL;
+  }
+  int max_count;
+  if (mode == 1) { /* Numeric: 10 bits per 3 digits */
+    max_count = (avail_bits * 3) / 10;
+  }
+  else if (mode == 2) { /* Alphanumeric: 11 bits per 2 chars */
+    max_count = (avail_bits * 2) / 11;
+  }
+  else if (mode == 4) { /* Byte: 8 bits per char */
+    max_count = avail_bits / 8;
+  }
+  else { /* Kanji: 13 bits per char */
+    max_count = avail_bits / 13;
+  }
+  if (count <= 0 || count > max_count) {
+    return NULL;
+  }
+
+  char *result = malloc(count + 1);
+  if (!result) {
+    return NULL;
+  }
+
+  if (mode == 4) { /* Byte */
+    for (int i = 0; i < count; i++) {
+      result[i] = (char)read_bits(data, len, &pos, 8);
+    }
+    result[count] = '\0';
+  }
+  else if (mode == 2) { /* Alphanumeric */
+    static const char tbl[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
+    int i = 0;
+    while (i + 1 < count) {
+      int v = read_bits(data, len, &pos, 11);
+      int hi = v / 45;
+      int lo = v % 45;
+      if (hi >= 45) {
+        free(result);
+        return NULL;
+      }
+      result[i] = tbl[hi];
+      result[i + 1] = tbl[lo];
+      i += 2;
+    }
+    if (i < count) {
+      int v = read_bits(data, len, &pos, 6);
+      if (v >= 45) {
+        free(result);
+        return NULL;
+      }
+      result[i] = tbl[v];
+    }
+    result[count] = '\0';
+  }
+  else if (mode == 1) { /* Numeric */
+    int i = 0;
+    while (i + 2 < count) {
+      int v = read_bits(data, len, &pos, 10);
+      result[i] = '0' + v / 100;
+      result[i + 1] = '0' + (v / 10) % 10;
+      result[i + 2] = '0' + v % 10;
+      i += 3;
+    }
+    if (count - i == 2) {
+      int v = read_bits(data, len, &pos, 7);
+      result[i] = '0' + v / 10;
+      result[i + 1] = '0' + v % 10;
+    }
+    else if (count - i == 1) {
+      result[i] = '0' + read_bits(data, len, &pos, 4);
+    }
+    result[count] = '\0';
+  }
+  else {
+    free(result);
+    return NULL;
+  }
+  return result;
+}
+
+/* Decode a sampled grid into a payload string, assuming canonical
+   orientation (finders at TL / TR / BL). Returns NULL on failure. Writes
+   the format-code Hamming distance to *out_fmt_dist if non-NULL. Writes
+   the Reed-Solomon correction cost (lower = more confident) to
+   *out_rs_cost if non-NULL. */
+static char *decode_grid_oriented(
+  uint8_t *grid,
+  uint8_t const *conf,
+  int qr_size,
+  int version,
+  int *out_fmt_dist,
+  int *out_rs_cost
+) {
+  /* Structural check on the three finder windows. Real QRs match the
+     7x7 finder pattern at (0,0) / (0, qr_size-7) / (qr_size-7, 0) almost
+     perfectly; hallucinated grids only match about half the 147 cells.
+     The 0.15 cap leaves room for moderate homography drift and sampling
+     jitter under blur/perspective while killing the small-module numeric
+     FPs that slipped past the timing alternation. */
+  float fer = finder_error_rate(grid, qr_size);
+  if (fer > 0.15f) {
+    return NULL;
+  }
+  /* Gate on the timing pattern before the expensive RS path runs. Triples
+     lifted from noise almost always flunk this (alternation is random).
+     Base threshold 0.22 gives real QRs under moderate blur/perspective
+     plenty of headroom while nearly every hallucinated grid sits around
+     0.5. When the three finder windows match their 7x7 template almost
+     perfectly (fer < 0.03) we're confident the triple is real — raise
+     the timing cap to 0.35 so photographs with paper curl or moderate
+     perspective drift don't get rejected on what is ultimately a grid-
+     sampling approximation error, not a false positive. */
+  float timing_cap = (fer < 0.03f) ? 0.35f : 0.22f;
+  if (timing_error_rate(grid, qr_size) > timing_cap) {
+    return NULL;
+  }
+  int ec_level, mask_pattern;
+  int fmt_dist = 16;
+  if (decode_format(grid, qr_size, &ec_level, &mask_pattern, &fmt_dist) < 0) {
+    return NULL;
+  }
+  if (out_fmt_dist) {
+    *out_fmt_dist = fmt_dist;
+  }
+  if (out_rs_cost) {
+    *out_rs_cost = 100000;
+  }
+  int max_bits = qr_size * qr_size;
+  uint8_t *data_bits = malloc(max_bits);
+  uint8_t *bit_conf = NULL;
+  if (conf) {
+    bit_conf = malloc(max_bits);
+    if (!bit_conf) {
+      free(data_bits);
+      return NULL;
+    }
+  }
+  if (!data_bits) {
+    free(bit_conf);
+    return NULL;
+  }
+  int nbits = extract_bits(
+    grid,
+    conf,
+    qr_size,
+    version,
+    mask_pattern,
+    data_bits,
+    bit_conf,
+    max_bits
+  );
+  int max_bytes = nbits / 8;
+  uint8_t *raw_bytes = malloc(max_bytes + 1);
+  uint8_t *byte_ambig = NULL;
+  if (bit_conf) {
+    byte_ambig = malloc(max_bytes + 1);
+    if (!byte_ambig) {
+      free(data_bits);
+      free(bit_conf);
+      free(raw_bytes);
+      return NULL;
+    }
+  }
+  if (!raw_bytes) {
+    free(data_bits);
+    free(bit_conf);
+    free(byte_ambig);
+    return NULL;
+  }
+  int nraw = bits_to_bytes(data_bits, bit_conf, nbits, raw_bytes, byte_ambig);
+  free(data_bits);
+  free(bit_conf);
+
+  int data_cw_count = nraw;
+  uint8_t *data_bytes = raw_bytes;
+  uint8_t *deint_buf = NULL;
+  int rs_cost = 100000;
+  if (version >= 1 && version <= 40 && ec_level >= 0 && ec_level <= 3) {
+    const ECInfo *info = &EC_TABLE[version - 1][ec_level];
+    int total_data =
+      info->g1_blocks * info->g1_data_cw + info->g2_blocks * info->g2_data_cw;
+    if (total_data > 0 && total_data <= nraw) {
+      deint_buf = malloc(total_data + 1);
+      if (deint_buf) {
+        data_cw_count =
+          deinterleave(raw_bytes, byte_ambig, nraw, info, deint_buf, &rs_cost);
+        data_bytes = deint_buf;
+      }
+      else {
+        data_cw_count = total_data;
+      }
+    }
+  }
+  char *decoded = decode_payload(data_bytes, data_cw_count, version);
+  free(raw_bytes);
+  free(byte_ambig);
+  free(deint_buf);
+  if (decoded) {
+    int dlen = (int)strlen(decoded);
+    for (int i = 0; i < dlen; i++) {
+      uint8_t ch = (uint8_t)decoded[i];
+      if (ch < 0x20 || ch > 0x7e) {
+        free(decoded);
+        return NULL;
+      }
+    }
+  }
+  if (decoded && out_rs_cost) {
+    *out_rs_cost = rs_cost;
+  }
+  return decoded;
+}
+
+/* Transpose an n*n byte buffer (grid[i*n+j] -> out[j*n+i]). */
+static void transpose_grid_u8(uint8_t const *src, uint8_t *dst, int n) {
+  for (int i = 0; i < n; i++) {
+    for (int j = 0; j < n; j++) {
+      dst[j * n + i] = src[i * n + j];
+    }
+  }
+}
+
+/* Decode a sampled grid, retrying with the matrix transposed when the
+   canonical orientation fails. A "mirrored" QR (ZXing terminology: the
+   symbol read as its own reflection, e.g. through a mirror or printed
+   wrong-way-round) appears with rows and columns swapped relative to the
+   spec. Finder and timing patterns are symmetric under transposition, so
+   those structural checks pass on both orientations, but decode_format
+   samples the wrong cells and the decode aborts. Transposing the grid
+   (and confidence buffer) puts the format, mask, alignment patterns, and
+   zigzag data reading back into canonical positions. Runs only after the
+   canonical decode returns NULL, so valid non-mirrored QRs never pay for
+   the retry and cannot be displaced by a spurious mirror decode. */
+static char *decode_grid(
+  uint8_t *grid,
+  uint8_t const *conf,
+  int qr_size,
+  int version,
+  int *out_fmt_dist,
+  int *out_rs_cost
+) {
+  char *d = decode_grid_oriented(
+    grid,
+    conf,
+    qr_size,
+    version,
+    out_fmt_dist,
+    out_rs_cost
+  );
+  if (d) {
+    return d;
+  }
+
+  int n = qr_size * qr_size;
+  uint8_t *tgrid = malloc((size_t)n);
+  if (!tgrid) {
+    return NULL;
+  }
+  uint8_t *tconf = NULL;
+  if (conf) {
+    tconf = malloc((size_t)n);
+    if (!tconf) {
+      free(tgrid);
+      return NULL;
+    }
+    transpose_grid_u8(conf, tconf, qr_size);
+  }
+  transpose_grid_u8(grid, tgrid, qr_size);
+
+  int fd = 16, rc = 100000;
+  char *dm = decode_grid_oriented(tgrid, tconf, qr_size, version, &fd, &rc);
+  free(tgrid);
+  free(tconf);
+  if (dm) {
+    if (out_fmt_dist) {
+      *out_fmt_dist = fd;
+    }
+    if (out_rs_cost) {
+      *out_rs_cost = rc;
+    }
+  }
+  return dm;
+}
+
+/* ---- Decode the grid under a homography, then perturb the sample offset
+   to hunt for a stronger decode ----
+   The 5-point voting in sample_grid_homography_offset_mode is robust to small
+   grid drift inside a single module, but if the WHOLE sampling grid is shifted
+   by a fraction of a module (typical when finder centers carry sub-module error
+   under blur or perspective), entire rows or columns get misread the same
+   way. A 1-bit error in the byte-mode count field is the most damaging
+   failure mode — it can truncate an 11-character decode to 3 characters
+   while still passing the format BCH cleanly. Re-sampling at small (dx, dy)
+   shifts in module space and keeping the best decode catches these
+   off-by-fraction grid misalignments. */
+static char *decode_with_perturbation(
+  uint8_t const *pixels,
+  uint8_t const *gray,
+  int width,
+  int height,
+  const double H[9],
+  int qr_size,
+  int version,
+  int *out_fmt_dist,
+  int *out_rs_cost
+) {
+  /* Sample all 9 offsets (canonical + 8 perturbations) and collect the
+     decodes with their format-distance scores. We then apply two acceptance
+     paths: (a) if canonical succeeds, allow a strictly-longer perturbation
+     that has the canonical result as a prefix to replace it (the byte-mode
+     count-truncation recovery — "c3Q" -> "c3QrgLROiE5"). (b) if canonical
+     fails, only accept a perturbation result that two or more perturbations
+     agree on (a consensus decode), because lone perturbation decodes from
+     triples whose canonical homography fails are almost always wrong same-
+     length samples that other triples would decode correctly. */
+#define PERT_N 10
+  /* Sample plan: 8 NN perturbations + 2 canonical samples (NN + bilinear).
+     Bilinear is only used at the canonical (0,0) offset — its purpose is
+     specifically to recover from the byte-mode count-truncation pattern
+     where NN nearest-pixel rounding clips the high bit of the count field.
+     Off-canonical bilinear samples introduce too many same-length wrong
+     decodes that distort the consensus vote in path (b). */
+  static const double offs[PERT_N][2] = {
+    {0.00, 0.00},
+    {0.00, 0.00}, /* p=0 NN canonical, p=1 bilinear canonical */
+    {0.30, 0.00},
+    {-0.30, 0.00},
+    {0.00, 0.30},
+    {0.00, -0.30},
+    {0.30, 0.30},
+    {-0.30, 0.30},
+    {0.30, -0.30},
+    {-0.30, -0.30},
+  };
+  char *decodes[PERT_N] = {0};
+  int fds[PERT_N];
+  int rs_costs[PERT_N];
+  size_t lens[PERT_N] = {0};
+  for (int i = 0; i < PERT_N; i++) {
+    fds[i] = 16;
+    rs_costs[i] = 100000;
+  }
+  for (int p = 0; p < PERT_N; p++) {
+    int use_bilinear = (p == 1);
+    uint8_t *conf = NULL;
+    uint8_t *g = sample_grid_homography_offset_mode(
+      pixels,
+      gray,
+      width,
+      height,
+      H,
+      qr_size,
+      offs[p][0],
+      offs[p][1],
+      use_bilinear,
+      gray ? &conf : NULL
+    );
+    if (!g) {
+      free(conf);
+      continue;
+    }
+    char *d = decode_grid(g, conf, qr_size, version, &fds[p], &rs_costs[p]);
+    free(g);
+    free(conf);
+    if (d) {
+      decodes[p] = d;
+      lens[p] = strlen(d);
+    }
+    /* Early exit if the NN canonical (p=0) is already a long, perfect
+       decode with no RS corrections — nothing can credibly improve on it. */
+    if (p == 0 && d && fds[0] == 0 && lens[0] >= 8 && rs_costs[0] == 0) {
+      break;
+    }
+  }
+
+  char *best = NULL;
+  int best_fd = 16;
+  int best_rs_out = 100000;
+
+  if (decodes[0]) {
+    /* Path (a): canonical succeeded. Replace either with (i) a strictly
+       longer decode that has the canonical as a prefix (the byte-mode
+       count-truncation recovery), or (ii) a same-length decode whose
+       Reed-Solomon correction cost is strictly lower (RS needed fewer
+       fixes, which is a strong signal that the perturbation hit cleaner
+       data bits even when format BCH ties at fmt_dist=0). */
+    best = decodes[0];
+    best_fd = fds[0];
+    int best_rs = rs_costs[0];
+    decodes[0] = NULL;
+    for (int p = 1; p < PERT_N; p++) {
+      if (!decodes[p]) {
+        continue;
+      }
+      int replace = 0;
+      if (lens[p] > strlen(best) &&
+          strncmp(decodes[p], best, strlen(best)) == 0) {
+        replace = 1;
+      }
+      else if (lens[p] == strlen(best) && rs_costs[p] < best_rs) {
+        replace = 1;
+      }
+      if (replace) {
+        free(best);
+        best = decodes[p];
+        best_fd = fds[p];
+        best_rs = rs_costs[p];
+        decodes[p] = NULL;
+      }
+    }
+    best_rs_out = best_rs;
+  }
+  else {
+    /* Path (b): canonical failed. Pick the perturbation decode that the
+       most other perturbations agree on, requiring at least two
+       agreements total. Tie-break: longer string, then lower fmt_dist.
+       Without consensus, return NULL so other triples can be tried. */
+    int best_idx = -1;
+    int best_count = 0;
+    for (int i = 1; i < PERT_N; i++) {
+      if (!decodes[i]) {
+        continue;
+      }
+      int count = 0;
+      for (int j = 1; j < PERT_N; j++) {
+        if (decodes[j] && strcmp(decodes[i], decodes[j]) == 0) {
+          count++;
+        }
+      }
+      int better = 0;
+      if (count > best_count) {
+        better = 1;
+      }
+      else if (count == best_count && best_idx >= 0) {
+        if (lens[i] > lens[best_idx]) {
+          better = 1;
+        }
+        else if (lens[i] == lens[best_idx] && fds[i] < fds[best_idx]) {
+          better = 1;
+        }
+      }
+      if (better) {
+        best_count = count;
+        best_idx = i;
+      }
+    }
+    if (best_idx >= 0 && best_count >= 2) {
+      best = decodes[best_idx];
+      best_fd = fds[best_idx];
+      best_rs_out = rs_costs[best_idx];
+      decodes[best_idx] = NULL;
+    }
+  }
+
+  for (int p = 0; p < PERT_N; p++) {
+    free(decodes[p]);
+  }
+#undef PERT_N
+  if (best) {
+    if (out_fmt_dist) {
+      *out_fmt_dist = best_fd;
+    }
+    if (out_rs_cost) {
+      *out_rs_cost = best_rs_out;
+    }
+  }
+  return best;
+}
+
+/* ---- Try to decode a single QR code from a triple ---- */
+
+static char *try_decode_triple(
+  uint8_t const *pixels,
+  uint8_t const *gray,
+  int width,
+  int height,
+  FinderPattern tl,
+  FinderPattern tr,
+  FinderPattern bl,
+  int *out_fmt_dist,
+  int *out_rs_cost,
+  Corners *out_corners,
+  Point2D out_finders[3],
+  double *out_module_size,
+  int *out_version,
+  int *out_qr_size,
+  Point2D out_alignments[QR_MAX_ALIGNMENTS],
+  int *out_alignment_count
+) {
+  float dist_t = fp_dist(tl, tr), dist_l = fp_dist(tl, bl);
+  float avg_d = (dist_t + dist_l) / 2.0f;
+  float avg_m = (tl.module_size + tr.module_size + bl.module_size) / 3.0f;
+  if (avg_m < 0.5f) {
+    return NULL;
+  }
+  float qr_sz_f = avg_d / avg_m + 7.0f;
+  int version = (int)((qr_sz_f - 21.0f + 2.0f) / 4.0f) + 1;
+  if (version < 1) {
+    version = 1;
+  }
+  if (version > 40) {
+    version = 40;
+  }
+  int qr_size = 21 + (version - 1) * 4;
+
+  /* Build a perspective transform for grid sampling. For v≥2 QRs we search
+     for the BR alignment pattern and use its pixel location as the 4th
+     homography point; under perspective distortion this beats the
+     parallelogram-BR assumption that affine sampling relies on. For v1 (no
+     alignment) we fall back to the parallelogram BR. */
+  int fmt_dist = 16;
+  int rs_cost = 100000;
+  char *decoded = NULL;
+
+  /* Track the best initial homography we have so timing-pattern refinement
+     has a starting point to predict module-centroid positions from. */
+  double H_primary[9];
+  int have_primary_H = 0;
+  int have_alignment = 0;
+  double alignment_x = 0, alignment_y = 0;
+
+  /* Track the homography (if any) associated with the winning decode so
+     callers can enumerate every alignment pattern the image exposes. Not
+     populated for the quadratic-warp fallback, which has no single H. */
+  double winning_H[9];
+  int have_winning_H = 0;
+
+  if (version >= 2) {
+    float sx0 = 3.5f, sy0 = 3.5f;
+    float sx1 = qr_size - 3.5f, sy1 = 3.5f;
+    float sx2 = 3.5f, sy2 = qr_size - 3.5f;
+    float denom = (sx0 - sx2) * (sy1 - sy2) - (sx1 - sx2) * (sy0 - sy2);
+    if (fabsf(denom) >= 1e-6f) {
+      float a11 =
+        ((tl.x - bl.x) * (sy1 - sy2) - (tr.x - bl.x) * (sy0 - sy2)) / denom;
+      float a12 =
+        ((tr.x - bl.x) * (sx0 - sx2) - (tl.x - bl.x) * (sx1 - sx2)) / denom;
+      float a21 =
+        ((tl.y - bl.y) * (sy1 - sy2) - (tr.y - bl.y) * (sy0 - sy2)) / denom;
+      float a22 =
+        ((tr.y - bl.y) * (sx0 - sx2) - (tl.y - bl.y) * (sx1 - sx2)) / denom;
+      float b1 = tl.x - a11 * sx0 - a12 * sy0;
+      float b2 = tl.y - a21 * sx0 - a22 * sy0;
+      float amx = qr_size - 6.5f, amy = qr_size - 6.5f;
+      float predicted_ax = a11 * amx + a12 * amy + b1;
+      float predicted_ay = a21 * amx + a22 * amy + b2;
+      /* Search radius scales with QR size: under severe perspective, the
+         affine-predicted BR drift grows with finder separation — a v8
+         receipt photo can have the real BR alignment 10+ modules from
+         the affine prediction. Clamp so the search area stays bounded. */
+      float search_r = avg_m * 10.0f;
+      if (version >= 7) {
+        float wide = avg_m * (float)(qr_size / 4);
+        if (wide > search_r) {
+          search_r = wide;
+        }
+        if (search_r > avg_m * 20.0f) {
+          search_r = avg_m * 20.0f;
+        }
+      }
+      float found_ax, found_ay;
+      if (find_alignment_pattern(
+            pixels,
+            width,
+            height,
+            predicted_ax,
+            predicted_ay,
+            avg_m,
+            search_r,
+            &found_ax,
+            &found_ay
+          )) {
+        have_alignment = 1;
+        alignment_x = found_ax;
+        alignment_y = found_ay;
+        double src[4][2] = {{sx0, sy0}, {sx1, sy1}, {amx, amy}, {sx2, sy2}};
+        double dst[4][2] =
+          {{tl.x, tl.y}, {tr.x, tr.y}, {found_ax, found_ay}, {bl.x, bl.y}};
+        double H[9];
+        if (compute_homography(src, dst, H)) {
+          /* Try up to two homography variants for v7+ QRs: the exact
+             4-point fit from the BR snap (accurate when the snap is clean)
+             and a least-squares fit over all found alignment patterns
+             (accurate under paper curl or severe perspective, which
+             leaves the 4-point fit drifting inside the grid). Decode with
+             the BR-snap H first, then fall back to the LS-refined H if
+             the primary fails. */
+          double H_variants[2][9];
+          memcpy(H_variants[0], H, sizeof(H));
+          int n_variants = 1;
+          if (version >= 7) {
+            enum { MAX_AP = 45 };
+            double ap_src[MAX_AP][2], ap_dst[MAX_AP][2];
+            int n_ap = find_all_alignment_patterns(
+              pixels,
+              width,
+              height,
+              version,
+              H,
+              avg_m,
+              2.0f,
+              ap_src,
+              ap_dst,
+              MAX_AP
+            );
+            double ap_src_a[MAX_AP][2], ap_dst_a[MAX_AP][2];
+            double H_affine[9] = {a11, a12, b1, a21, a22, b2, 0.0, 0.0, 1.0};
+            int n_ap_a = find_all_alignment_patterns(
+              pixels,
+              width,
+              height,
+              version,
+              H_affine,
+              avg_m,
+              4.0f,
+              ap_src_a,
+              ap_dst_a,
+              MAX_AP
+            );
+            if (n_ap_a > n_ap) {
+              n_ap = n_ap_a;
+              memcpy(ap_src, ap_src_a, sizeof(ap_src_a));
+              memcpy(ap_dst, ap_dst_a, sizeof(ap_dst_a));
+            }
+            double H_refined[9];
+            memcpy(H_refined, H, sizeof(H_refined));
+            int refined_ok = 0;
+            for (int iter = 0; iter < 3 && n_ap >= 2; iter++) {
+              double pts_src[MAX_AP + 4][2];
+              double pts_dst[MAX_AP + 4][2];
+              int np = 0;
+              pts_src[np][0] = sx0;
+              pts_src[np][1] = sy0;
+              pts_dst[np][0] = tl.x;
+              pts_dst[np][1] = tl.y;
+              np++;
+              pts_src[np][0] = sx1;
+              pts_src[np][1] = sy1;
+              pts_dst[np][0] = tr.x;
+              pts_dst[np][1] = tr.y;
+              np++;
+              pts_src[np][0] = sx2;
+              pts_src[np][1] = sy2;
+              pts_dst[np][0] = bl.x;
+              pts_dst[np][1] = bl.y;
+              np++;
+              for (int i = 0; i < n_ap; i++) {
+                pts_src[np][0] = ap_src[i][0];
+                pts_src[np][1] = ap_src[i][1];
+                pts_dst[np][0] = ap_dst[i][0];
+                pts_dst[np][1] = ap_dst[i][1];
+                np++;
+              }
+              double H_ls[9];
+              if (!compute_homography_ls(pts_src, pts_dst, np, H_ls)) {
+                break;
+              }
+              /* Outlier rejection: discard any AP whose reprojection is
+                 more than 1.5 modules off the fit — those are wrong snaps
+                 (random dark clusters that match the 5x5 template). Real
+                 APs sit within 0.5 modules of the fit. Finders anchor the
+                 three known corners and are kept unconditionally. */
+              double pts_src_in[MAX_AP + 4][2];
+              double pts_dst_in[MAX_AP + 4][2];
+              int np_in = 3;
+              for (int i = 0; i < 3; i++) {
+                pts_src_in[i][0] = pts_src[i][0];
+                pts_src_in[i][1] = pts_src[i][1];
+                pts_dst_in[i][0] = pts_dst[i][0];
+                pts_dst_in[i][1] = pts_dst[i][1];
+              }
+              float thresh = avg_m * 1.5f;
+              for (int i = 3; i < np; i++) {
+                double rx, ry;
+                if (!project_homography(
+                      H_ls,
+                      pts_src[i][0],
+                      pts_src[i][1],
+                      &rx,
+                      &ry
+                    )) {
+                  continue;
+                }
+                double dx = rx - pts_dst[i][0];
+                double dy = ry - pts_dst[i][1];
+                if (dx * dx + dy * dy <= thresh * thresh) {
+                  pts_src_in[np_in][0] = pts_src[i][0];
+                  pts_src_in[np_in][1] = pts_src[i][1];
+                  pts_dst_in[np_in][0] = pts_dst[i][0];
+                  pts_dst_in[np_in][1] = pts_dst[i][1];
+                  np_in++;
+                }
+              }
+              if (np_in >= 4 && np_in < np) {
+                double H_ls2[9];
+                if (compute_homography_ls(
+                      pts_src_in,
+                      pts_dst_in,
+                      np_in,
+                      H_ls2
+                    )) {
+                  memcpy(H_ls, H_ls2, sizeof(H_ls2));
+                }
+              }
+              memcpy(H_refined, H_ls, sizeof(H_refined));
+              refined_ok = 1;
+              n_ap = find_all_alignment_patterns(
+                pixels,
+                width,
+                height,
+                version,
+                H_refined,
+                avg_m,
+                2.0f,
+                ap_src,
+                ap_dst,
+                MAX_AP
+              );
+            }
+            if (refined_ok) {
+              memcpy(H_variants[1], H_refined, sizeof(H_refined));
+              n_variants = 2;
+            }
+          }
+          memcpy(H_primary, H, sizeof(H));
+          have_primary_H = 1;
+          int fd = 16;
+          int rc = 100000;
+          char *d = NULL;
+          for (int v_idx = 0; v_idx < n_variants && !d; v_idx++) {
+            fd = 16;
+            rc = 100000;
+            d = decode_with_perturbation(
+              pixels,
+              gray,
+              width,
+              height,
+              H_variants[v_idx],
+              qr_size,
+              version,
+              &fd,
+              &rc
+            );
+            if (d) {
+              memcpy(winning_H, H_variants[v_idx], sizeof(winning_H));
+              have_winning_H = 1;
+            }
+          }
+          /* If neither variant decoded, try each found alignment pattern
+             as the 4th homography point on its own (exact 3-finders + 1-AP
+             fit). Covers severe perspective where one individual AP is
+             well-located but the ensemble fit gets pulled off by its
+             outliers. */
+          if (!d && version >= 7) {
+            enum { MAX_AP = 45 };
+            double ap_src[MAX_AP][2], ap_dst[MAX_AP][2];
+            double H_affine[9] = {a11, a12, b1, a21, a22, b2, 0.0, 0.0, 1.0};
+            int n_ap = find_all_alignment_patterns(
+              pixels,
+              width,
+              height,
+              version,
+              H_affine,
+              avg_m,
+              8.0f,
+              ap_src,
+              ap_dst,
+              MAX_AP
+            );
+            for (int ai = 0; ai < n_ap && !d; ai++) {
+              double src_i[4][2] = {
+                {sx0, sy0},
+                {sx1, sy1},
+                {ap_src[ai][0], ap_src[ai][1]},
+                {sx2, sy2}
+              };
+              double dst_i[4][2] = {
+                {tl.x, tl.y},
+                {tr.x, tr.y},
+                {ap_dst[ai][0], ap_dst[ai][1]},
+                {bl.x, bl.y}
+              };
+              double H_i[9];
+              if (!compute_homography(src_i, dst_i, H_i)) {
+                continue;
+              }
+              fd = 16;
+              rc = 100000;
+              d = decode_with_perturbation(
+                pixels,
+                gray,
+                width,
+                height,
+                H_i,
+                qr_size,
+                version,
+                &fd,
+                &rc
+              );
+              if (d) {
+                memcpy(winning_H, H_i, sizeof(winning_H));
+                have_winning_H = 1;
+              }
+            }
+          }
+          if (d) {
+            decoded = d;
+            fmt_dist = fd;
+            rs_cost = rc;
+          }
+        }
+        /* If the canonical alignment-based homography failed, sweep a
+           small grid of alignment-point perturbations and retry. Paper curl
+           and perspective foreshortening mean the real alignment pattern
+           can sit several pixels from where find_alignment_pattern snaps
+           to, while the homography is extremely sensitive to this 4th
+           point — a few pixels at the BR alignment translate to sub-module
+           drift across the data area, tripping the timing and finder
+           gates even when the three finder positions are pin-accurate.
+           Sweep radius scales with module size (the residual alignment
+           uncertainty is always a fraction of a module); 2x2 module
+           radius at half-module steps is cheap (25 samples) and covers
+           the drift seen on moderately curled photos. */
+        if (!decoded) {
+          float step = avg_m * 0.5f;
+          float radius = avg_m * 1.5f;
+          int n_steps = (int)(radius / step);
+          char *best_sweep = NULL;
+          int best_sweep_fd = 16;
+          int best_sweep_rc = 100000;
+          double best_sweep_H[9];
+          int have_best_sweep_H = 0;
+          for (int dy = -n_steps; dy <= n_steps; dy++) {
+            for (int dx = -n_steps; dx <= n_steps; dx++) {
+              if (dx == 0 && dy == 0) {
+                continue; /* already tried */
+              }
+              double pert_dst[4][2] = {
+                {tl.x, tl.y},
+                {tr.x, tr.y},
+                {found_ax + dx * step, found_ay + dy * step},
+                {bl.x, bl.y}
+              };
+              double Hp[9];
+              if (!compute_homography(src, pert_dst, Hp)) {
+                continue;
+              }
+              int fd = 16;
+              int rc = 100000;
+              char *d = decode_with_perturbation(
+                pixels,
+                gray,
+                width,
+                height,
+                Hp,
+                qr_size,
+                version,
+                &fd,
+                &rc
+              );
+              if (!d) {
+                continue;
+              }
+              /* Take the lowest-cost decode across the whole sweep rather
+                 than stopping on the first hit — some perturbed alignments
+                 yield a consensus decode that is still garbage (random
+                 alphanumeric strings with fd=0 and rc=0 do occur when
+                 sampling happens to line up with a false-but-self-
+                 consistent grid). Prefer longer strings and lower RS
+                 cost so the best geometric fit wins. */
+              int replace = 0;
+              if (!best_sweep) {
+                replace = 1;
+              }
+              else if (fd < best_sweep_fd) {
+                replace = 1;
+              }
+              else if (fd == best_sweep_fd &&
+                       (int)strlen(d) > (int)strlen(best_sweep)) {
+                replace = 1;
+              }
+              else if (fd == best_sweep_fd &&
+                       (int)strlen(d) == (int)strlen(best_sweep) &&
+                       rc < best_sweep_rc) {
+                replace = 1;
+              }
+              if (replace) {
+                free(best_sweep);
+                best_sweep = d;
+                best_sweep_fd = fd;
+                best_sweep_rc = rc;
+                memcpy(best_sweep_H, Hp, sizeof(best_sweep_H));
+                have_best_sweep_H = 1;
+              }
+              else {
+                free(d);
+              }
+            }
+          }
+          /* Only accept sweep results with a strong confidence signal:
+             perfect format BCH (fd = 0) and near-zero RS correction cost.
+             Random perturbations in a QR-free region can consensus-
+             decode as alphanumeric garbage with fd=0 but substantial RS
+             cost (≥ 100), so locking the rc threshold low rejects those.
+             Real decodes with the right alignment typically need zero
+             corrections; a small tolerance (≤ 5) lets through slightly
+             noisy captures without admitting hallucinated decodes. */
+          if (best_sweep && best_sweep_fd == 0 && best_sweep_rc <= 5) {
+            decoded = best_sweep;
+            fmt_dist = best_sweep_fd;
+            rs_cost = best_sweep_rc;
+            if (have_best_sweep_H) {
+              memcpy(winning_H, best_sweep_H, sizeof(winning_H));
+              have_winning_H = 1;
+            }
+          }
+          else {
+            free(best_sweep);
+          }
+        }
+      }
+    }
+  }
+
+  /* If no homography-based decode worked yet, try the plain parallelogram
+     BR (affine-equivalent). */
+  if (!decoded) {
+    float br_x = tr.x + bl.x - tl.x;
+    float br_y = tr.y + bl.y - tl.y;
+    double src[4][2] = {
+      {3.5, 3.5},
+      {qr_size - 3.5, 3.5},
+      {qr_size - 3.5, qr_size - 3.5},
+      {3.5, qr_size - 3.5}
+    };
+    double dst[4][2] = {{tl.x, tl.y}, {tr.x, tr.y}, {br_x, br_y}, {bl.x, bl.y}};
+    double H[9];
+    if (compute_homography(src, dst, H)) {
+      if (!have_primary_H) {
+        memcpy(H_primary, H, sizeof(H));
+        have_primary_H = 1;
+      }
+      int fd = 16;
+      int rc = 100000;
+      char *d = decode_with_perturbation(
+        pixels,
+        gray,
+        width,
+        height,
+        H,
+        qr_size,
+        version,
+        &fd,
+        &rc
+      );
+      if (d) {
+        decoded = d;
+        fmt_dist = fd;
+        rs_cost = rc;
+        memcpy(winning_H, H, sizeof(winning_H));
+        have_winning_H = 1;
+      }
+    }
+  }
+
+  /* Timing-pattern homography refinement. When a 4-point homography (from
+     alignment pattern or parallelogram BR) can't produce a clean decode,
+     the usual cause on photos with any perspective or paper curl is
+     sub-module drift in the QR's interior — the 4 control points anchor
+     the corners but nothing constrains the transform *between* them.
+     Observing pixel-space centroids of every known-dark timing module
+     gives us ≈ 2·(qr_size − 16) extra correspondences threading the
+     code's interior, and solving an over-determined DLT over the union
+     of control points + timing observations averages out the distortion
+     the 4-point fit can't model. Run it only after the canonical paths
+     have failed (it adds real cost: ~2·qr_size centroid searches + an
+     8×8 normal-equation solve + another 10-perturbation decode pass). */
+  if (!decoded && gray && have_primary_H && version >= 2) {
+    double H_cur[9];
+    memcpy(H_cur, H_primary, sizeof(H_cur));
+    /* Iterate refinement a few times — each pass re-predicts timing-
+       module positions through the currently best H, re-observes
+       centroids in the pixel buffer, and re-fits. Three rounds is
+       usually enough to converge; diminishing returns beyond that. */
+    for (int iter = 0; iter < 3 && !decoded; iter++) {
+      double H_ref[9];
+      if (!refine_h_with_timing(
+            gray,
+            width,
+            height,
+            H_cur,
+            qr_size,
+            avg_m,
+            tl,
+            tr,
+            bl,
+            have_alignment,
+            alignment_x,
+            alignment_y,
+            H_ref
+          )) {
+        break;
+      }
+      memcpy(H_cur, H_ref, sizeof(H_cur));
+      int fd = 16;
+      int rc = 100000;
+      char *d = decode_with_perturbation(
+        pixels,
+        gray,
+        width,
+        height,
+        H_ref,
+        qr_size,
+        version,
+        &fd,
+        &rc
+      );
+      if (d) {
+        decoded = d;
+        fmt_dist = fd;
+        rs_cost = rc;
+        memcpy(winning_H, H_ref, sizeof(winning_H));
+        have_winning_H = 1;
+      }
+    }
+
+    /* Last-resort attempt: quadratic (polynomial) warp sampling. When
+       every homography we can build still fails to decode, the surface
+       itself isn't planar — typical case is a QR printed on a curled
+       receipt. Fit a bivariate degree-2 polynomial to 3 finders +
+       alignment + timing observations and sample the grid with it. The
+       quadratic warp can follow a cylindrical bend that no single
+       homography can model. Only used when the refined H's decode
+       failed, so the added cost is bounded to the hardest images. */
+    if (!decoded) {
+      int fd = 16;
+      int rc = 100000;
+      char *d = try_decode_quadratic(
+        pixels,
+        gray,
+        width,
+        height,
+        qr_size,
+        version,
+        tl,
+        tr,
+        bl,
+        have_alignment,
+        alignment_x,
+        alignment_y,
+        H_cur,
+        avg_m,
+        &fd,
+        &rc
+      );
+      if (d) {
+        decoded = d;
+        fmt_dist = fd;
+        rs_cost = rc;
+        /* Quadratic warp has no homography; H_cur from the last timing
+           refinement is the closest planar approximation. Good enough to
+           seed alignment-pattern search — find_all_alignment_patterns
+           snaps to the pixel-space pattern anyway. */
+        memcpy(winning_H, H_cur, sizeof(winning_H));
+        have_winning_H = 1;
+      }
+    }
+  }
+
+  if (!decoded) {
+    return NULL;
+  }
+  if (out_fmt_dist) {
+    *out_fmt_dist = fmt_dist;
+  }
+  if (out_rs_cost) {
+    *out_rs_cost = rs_cost;
+  }
+
+  {
+    /* Compute QR code outer corners from finder pattern centers.
+       Finder centers sit at module (3.5, 3.5) from their corners. */
+    if (out_corners) {
+      float ux = (tr.x - tl.x) / (qr_size - 7.0f);
+      float uy = (tr.y - tl.y) / (qr_size - 7.0f);
+      float vx = (bl.x - tl.x) / (qr_size - 7.0f);
+      float vy = (bl.y - tl.y) / (qr_size - 7.0f);
+      out_corners->tl_x = tl.x - 3.5f * (ux + vx);
+      out_corners->tl_y = tl.y - 3.5f * (uy + vy);
+      out_corners->tr_x = tr.x + 3.5f * (ux - vx);
+      out_corners->tr_y = tr.y + 3.5f * (uy - vy);
+      float br_center_x = tr.x + bl.x - tl.x;
+      float br_center_y = tr.y + bl.y - tl.y;
+      out_corners->br_x = br_center_x + 3.5f * (ux + vx);
+      out_corners->br_y = br_center_y + 3.5f * (uy + vy);
+      out_corners->bl_x = bl.x - 3.5f * (ux - vx);
+      out_corners->bl_y = bl.y - 3.5f * (uy - vy);
+    }
+    if (out_finders) {
+      out_finders[0].x = tl.x;
+      out_finders[0].y = tl.y;
+      out_finders[1].x = tr.x;
+      out_finders[1].y = tr.y;
+      out_finders[2].x = bl.x;
+      out_finders[2].y = bl.y;
+    }
+    if (out_module_size) {
+      float ux = (tr.x - tl.x) / (qr_size - 7.0f);
+      float uy = (tr.y - tl.y) / (qr_size - 7.0f);
+      float vx = (bl.x - tl.x) / (qr_size - 7.0f);
+      float vy = (bl.y - tl.y) / (qr_size - 7.0f);
+      float mu = sqrtf(ux * ux + uy * uy);
+      float mv = sqrtf(vx * vx + vy * vy);
+      *out_module_size = (mu + mv) / 2.0f;
+    }
+    if (out_version) {
+      *out_version = version;
+    }
+    if (out_qr_size) {
+      *out_qr_size = qr_size;
+    }
+    if (out_alignment_count) {
+      *out_alignment_count = 0;
+    }
+    /* Enumerate every alignment pattern the decoder could locate in the
+       binarized buffer so callers (e.g. qr_draw) can visualise them.
+       v1 has none; v2–6 have 1 (BR); v7+ have a spec-defined grid. Uses
+       the outer-corners homography as the prediction frame — find_all_*
+       snaps to the actual dark 5x5 pattern in pixel space, so prediction
+       accuracy only has to put us within search_r of the true center. */
+    if (out_alignments && out_alignment_count && version >= 2 && out_corners) {
+      double src[4][2] = {
+        {0.0, 0.0},
+        {(double)qr_size, 0.0},
+        {(double)qr_size, (double)qr_size},
+        {0.0, (double)qr_size}
+      };
+      double dst[4][2] = {
+        {out_corners->tl_x, out_corners->tl_y},
+        {out_corners->tr_x, out_corners->tr_y},
+        {out_corners->br_x, out_corners->br_y},
+        {out_corners->bl_x, out_corners->bl_y}
+      };
+      double H[9];
+      if (have_winning_H) {
+        memcpy(H, winning_H, sizeof(H));
+      }
+      if (have_winning_H || compute_homography(src, dst, H)) {
+        /* Enumerate every spec-defined alignment-pattern grid position
+           for this version, projecting each module-space centre through
+           H to predict its pixel location. Then refine each prediction
+           against the binarized buffer if find_alignment_pattern can
+           snap to a real 5x5 template — otherwise keep the prediction
+           (useful for qr_draw even when the binarization degraded the
+           pattern enough that the snap fails). */
+        int nvals = QR_ALIGN_GRID[version][0];
+        int first = (nvals >= 1) ? QR_ALIGN_GRID[version][1] : 0;
+        int last = (nvals >= 1) ? QR_ALIGN_GRID[version][nvals] : 0;
+        int n_out = 0;
+        for (int ri = 0; ri < nvals && n_out < QR_MAX_ALIGNMENTS; ri++) {
+          int row = QR_ALIGN_GRID[version][1 + ri];
+          for (int ci = 0; ci < nvals && n_out < QR_MAX_ALIGNMENTS; ci++) {
+            int col = QR_ALIGN_GRID[version][1 + ci];
+            if ((row == first && col == first) ||
+                (row == first && col == last) ||
+                (row == last && col == first)) {
+              continue;
+            }
+            double mx = (double)col + 0.5;
+            double my = (double)row + 0.5;
+            double pred_x, pred_y;
+            if (!project_homography(H, mx, my, &pred_x, &pred_y)) {
+              continue;
+            }
+            float snap_x = (float)pred_x, snap_y = (float)pred_y;
+            /* 5-module search radius is the same tolerance used elsewhere
+               in the decoder for paper-curled captures where the
+               module-size estimate can drift up to ±30% locally. */
+            find_alignment_pattern(
+              pixels,
+              width,
+              height,
+              (float)pred_x,
+              (float)pred_y,
+              (float)(*out_module_size),
+              (float)(*out_module_size) * 5.0f,
+              &snap_x,
+              &snap_y
+            );
+            out_alignments[n_out].x = snap_x;
+            out_alignments[n_out].y = snap_y;
+            n_out++;
+          }
+        }
+        *out_alignment_count = n_out;
+      }
+    }
+  }
+  return decoded;
+}
+
+/* ---- Main API ---- */
+
+/* Run the finder + decode pipeline on a single binary buffer.
+   best_* are initialized by the caller and refined across multiple attempts
+   (e.g. one attempt per binarization strategy). gray is passed for
+   subpixel finder-center refinement. */
+static void try_pipeline(
+  uint8_t const *bin,
+  uint8_t const *gray,
+  int w,
+  int h,
+  char **best_decoded,
+  int *best_fmt_dist,
+  int *best_rs_cost,
+  Corners *best_corners,
+  Point2D best_finders[3],
+  double *best_module_size,
+  int *best_version,
+  int *best_qr_size,
+  Point2D best_alignments[QR_MAX_ALIGNMENTS],
+  int *best_alignment_count
+) {
+  FinderPattern fps[MAX_CANDIDATES];
+  int nfp = find_finders(bin, w, h, fps, MAX_CANDIDATES);
+  if (nfp < 3) {
+    return;
+  }
+  for (int i = 0; i < nfp; i++) {
+    refine_finder_center(gray, w, h, &fps[i]);
+  }
+
+  FinderPattern triples[MAX_TRIPLES][3];
+  int n_triples = select_and_rank(bin, w, h, fps, nfp, triples, MAX_TRIPLES);
+
+  for (int t = 0; t < n_triples; t++) {
+    int fmt_dist = 16;
+    int rs_cost = 100000;
+    Corners corners = {0};
+    Point2D finders[3];
+    double module_size = 0;
+    int version = 0;
+    int qr_size = 0;
+    Point2D alignments[QR_MAX_ALIGNMENTS];
+    int alignment_count = 0;
+    char *decoded = try_decode_triple(
+      bin,
+      gray,
+      w,
+      h,
+      triples[t][0],
+      triples[t][1],
+      triples[t][2],
+      &fmt_dist,
+      &rs_cost,
+      &corners,
+      finders,
+      &module_size,
+      &version,
+      &qr_size,
+      alignments,
+      &alignment_count
+    );
+    if (decoded) {
+      /* Composite score: lower fmt_dist wins, tie-break first on decoded
+         length (longer decodes mean a larger byte-mode count field
+         survived), then on Reed-Solomon cost (lower = the data block
+         decoded with fewer error corrections, which discriminates between
+         two "Mz4Y9X" candidates where one has clean RS and the other is
+         RS-uncorrectable but happens to share the same fmt_dist). */
+      int dlen = (int)strlen(decoded);
+      int replace = 0;
+      if (fmt_dist < *best_fmt_dist) {
+        replace = 1;
+      }
+      else if (fmt_dist == *best_fmt_dist && *best_decoded != NULL) {
+        int prev_len = (int)strlen(*best_decoded);
+        if (dlen > prev_len) {
+          replace = 1;
+        }
+        else if (dlen == prev_len && rs_cost < *best_rs_cost) {
+          replace = 1;
+        }
+      }
+      if (replace) {
+        free(*best_decoded);
+        *best_decoded = decoded;
+        *best_fmt_dist = fmt_dist;
+        *best_rs_cost = rs_cost;
+        *best_corners = corners;
+        for (int i = 0; i < 3; i++) {
+          best_finders[i] = finders[i];
+        }
+        *best_module_size = module_size;
+        *best_version = version;
+        *best_qr_size = qr_size;
+        *best_alignment_count = alignment_count;
+        for (int i = 0; i < alignment_count; i++) {
+          best_alignments[i] = alignments[i];
+        }
+      }
+      else {
+        free(decoded);
+      }
+    }
+  }
+}
+
+/* Runs the full binarizer/preprocessor cascade against `gray_pixels`. The
+   sequence is cheapest-first; each attempt short-circuits once a
+   high-confidence decode (fmt_dist <= 1, length >= 5) is in hand, so the
+   work only expands as far as the image demands. Called by the public
+   decoder at each pyramid level and once again on the inverted buffer —
+   which lets light-on-dark QRs (level_13) ride the same Sauvola/upsample/
+   median passes the normal-polarity pass has. `is_finest_level` gates the
+   native-resolution-only passes (2x/3x upsample), which exist to rescue
+   tiny QRs at native scale; upsampling from a downsampled pyramid level
+   is strictly worse than running those passes at level 0. */
+static void run_all_attempts(
+  uint8_t const *gray_pixels,
+  int w,
+  int h,
+  int is_finest_level,
+  char **best_decoded,
+  int *best_fmt_dist,
+  int *best_rs_cost,
+  Corners *best_corners,
+  Point2D best_finders[3],
+  double *best_module_size,
+  int *best_version,
+  int *best_qr_size,
+  Point2D best_alignments[QR_MAX_ALIGNMENTS],
+  int *best_alignment_count
+) {
+  /* Attempt 1: Otsu on original. */
+  uint8_t *bin = binarize_global_otsu(gray_pixels, w, h);
+  if (bin) {
+    try_pipeline(
+      bin,
+      gray_pixels,
+      w,
+      h,
+      best_decoded,
+      best_fmt_dist,
+      best_rs_cost,
+      best_corners,
+      best_finders,
+      best_module_size,
+      best_version,
+      best_qr_size,
+      best_alignments,
+      best_alignment_count
+    );
+    free(bin);
+  }
+
+  /* Attempt 2: zxing-cpp hybrid on original. Sparse-threshold per 8x8 block
+     plus 5x5 neighbour smoothing — low-contrast interior blocks inherit a
+     threshold from contrasty edge blocks instead of falling back to a
+     whole-image Otsu. Primary recovery path for faded / pastel palettes. */
+  if (!(*best_decoded && *best_fmt_dist <= 1 && strlen(*best_decoded) >= 5)) {
+    bin = binarize_hybrid(gray_pixels, w, h);
+    if (bin) {
+      try_pipeline(
+        bin,
+        gray_pixels,
+        w,
+        h,
+        best_decoded,
+        best_fmt_dist,
+        best_rs_cost,
+        best_corners,
+        best_finders,
+        best_module_size,
+        best_version,
+        best_qr_size,
+        best_alignments,
+        best_alignment_count
+      );
+      free(bin);
+    }
+  }
+
+  /* Attempt 3: adaptive on original. */
+  if (!(*best_decoded && *best_fmt_dist <= 1 && strlen(*best_decoded) >= 5)) {
+    bin = binarize_adaptive(gray_pixels, w, h);
+    if (bin) {
+      try_pipeline(
+        bin,
+        gray_pixels,
+        w,
+        h,
+        best_decoded,
+        best_fmt_dist,
+        best_rs_cost,
+        best_corners,
+        best_finders,
+        best_module_size,
+        best_version,
+        best_qr_size,
+        best_alignments,
+        best_alignment_count
+      );
+      free(bin);
+    }
+  }
+
+  /* The remaining attempts (Sauvola, unsharp, upsample) target large,
+     visually-degraded photographs. On tiny synthetic buffers they are more
+     likely to hallucinate a decode from noise than to recover a real QR
+     — anything smaller than ~100 px on the short side cannot plausibly
+     hold a decodable v1 QR (≥ ~5 px/module × 21 modules + quiet zone). */
+  int small_image = (w < 100 || h < 100);
+
+  /* Attempt 3: Sauvola on original. Targets faded / low-contrast modules
+     where adaptive's fixed min_std fallback misclassifies. */
+  if (!small_image &&
+      !(*best_decoded && *best_fmt_dist <= 1 && strlen(*best_decoded) >= 5)) {
+    bin = binarize_sauvola(gray_pixels, w, h);
+    if (bin) {
+      try_pipeline(
+        bin,
+        gray_pixels,
+        w,
+        h,
+        best_decoded,
+        best_fmt_dist,
+        best_rs_cost,
+        best_corners,
+        best_finders,
+        best_module_size,
+        best_version,
+        best_qr_size,
+        best_alignments,
+        best_alignment_count
+      );
+      free(bin);
+    }
+  }
+
+  /* Attempt 4: unsharp mask + Otsu, then unsharp mask + adaptive. Targets
+     blurred QRs (level_4/5 generators add Gaussian blur radius up to 1.8). */
+  if (!small_image &&
+      !(*best_decoded && *best_fmt_dist <= 1 && strlen(*best_decoded) >= 5)) {
+    uint8_t *sharp = unsharp_mask_gray(gray_pixels, w, h);
+    if (sharp) {
+      bin = binarize_global_otsu(sharp, w, h);
+      if (bin) {
+        try_pipeline(
+          bin,
+          sharp,
+          w,
+          h,
+          best_decoded,
+          best_fmt_dist,
+          best_rs_cost,
+          best_corners,
+          best_finders,
+          best_module_size,
+          best_version,
+          best_qr_size,
+          best_alignments,
+          best_alignment_count
+        );
+        free(bin);
+      }
+      if (!(*best_decoded && *best_fmt_dist <= 1 && strlen(*best_decoded) >= 5
+          )) {
+        bin = binarize_adaptive(sharp, w, h);
+        if (bin) {
+          try_pipeline(
+            bin,
+            sharp,
+            w,
+            h,
+            best_decoded,
+            best_fmt_dist,
+            best_rs_cost,
+            best_corners,
+            best_finders,
+            best_module_size,
+            best_version,
+            best_qr_size,
+            best_alignments,
+            best_alignment_count
+          );
+          free(bin);
+        }
+      }
+      free(sharp);
+    }
+  }
+
+  /* Attempt 5: 2x bilinear upsample + Otsu, then upsample + adaptive.
+     Targets tiny QRs whose modules are too small for finder ratio checks
+     at native resolution. Coordinates from any winning upsampled decode are
+     scaled back by 1/n before returning. Attempt 5b (3x) follows when 2x
+     still doesn't yield a confident decode; helpful for QRs with mod < 3 px
+     where even doubled modules remain on the edge of the ratio tolerance.
+     Skipped at coarser pyramid levels: upsampling a downsampled buffer is
+     strictly worse than running this at level 0 on the original data. */
+  for (int n_try = 2; n_try <= 3; n_try++) {
+    if (small_image || !is_finest_level) {
+      break;
+    }
+    /* Skip the upscaled passes when we already have a long, perfect-format
+       decode at the native scale; otherwise always try, because a tiny QR
+       (modules < 4 px) cannot reliably decode at native resolution and a
+       length-5 wrong decode (e.g. "Mz4Y9c" with fd=0 + uncorrectable RS)
+       must not block 3x exploration that may surface "Mz4Y9a". */
+    if (*best_decoded && *best_fmt_dist == 0 && strlen(*best_decoded) >= 10) {
+      break;
+    }
+    int wn = w * n_try;
+    int hn = h * n_try;
+    uint8_t *grayn = upsample_nx_bilinear(gray_pixels, w, h, n_try);
+    if (!grayn) {
+      continue;
+    }
+    char *best_decoded_up = NULL;
+    Corners best_corners_up = {0};
+    Point2D best_finders_up[3] = {{0, 0}, {0, 0}, {0, 0}};
+    double best_module_size_up = 0;
+    int best_fmt_dist_up = 16;
+    int best_rs_cost_up = 100000;
+    int best_version_up = 0;
+    int best_qr_size_up = 0;
+    Point2D best_alignments_up[QR_MAX_ALIGNMENTS];
+    int best_alignment_count_up = 0;
+
+    bin = binarize_global_otsu(grayn, wn, hn);
+    if (bin) {
+      try_pipeline(
+        bin,
+        grayn,
+        wn,
+        hn,
+        &best_decoded_up,
+        &best_fmt_dist_up,
+        &best_rs_cost_up,
+        &best_corners_up,
+        best_finders_up,
+        &best_module_size_up,
+        &best_version_up,
+        &best_qr_size_up,
+        best_alignments_up,
+        &best_alignment_count_up
+      );
+      free(bin);
+    }
+    if (!(best_decoded_up && best_fmt_dist_up <= 1)) {
+      bin = binarize_adaptive(grayn, wn, hn);
+      if (bin) {
+        try_pipeline(
+          bin,
+          grayn,
+          wn,
+          hn,
+          &best_decoded_up,
+          &best_fmt_dist_up,
+          &best_rs_cost_up,
+          &best_corners_up,
+          best_finders_up,
+          &best_module_size_up,
+          &best_version_up,
+          &best_qr_size_up,
+          best_alignments_up,
+          &best_alignment_count_up
+        );
+        free(bin);
+      }
+    }
+    free(grayn);
+
+    /* Promote the upsampled result if it improves on what we already have:
+       lower fmt_dist wins, tie-break on longer decode length, then on
+       lower RS cost. Mirrors the scoring inside try_pipeline. Scale
+       spatial outputs back to native coordinates by 1/n. */
+    if (best_decoded_up) {
+      int replace = 0;
+      int prev_len = (*best_decoded) ? (int)strlen(*best_decoded) : -1;
+      int new_len = (int)strlen(best_decoded_up);
+      if (!*best_decoded) {
+        replace = 1;
+      }
+      else if (best_fmt_dist_up < *best_fmt_dist) {
+        replace = 1;
+      }
+      else if (best_fmt_dist_up == *best_fmt_dist && new_len > prev_len) {
+        replace = 1;
+      }
+      else if (best_fmt_dist_up == *best_fmt_dist && new_len == prev_len &&
+               best_rs_cost_up < *best_rs_cost) {
+        replace = 1;
+      }
+      if (replace) {
+        double inv = 1.0 / (double)n_try;
+        free(*best_decoded);
+        *best_decoded = best_decoded_up;
+        *best_fmt_dist = best_fmt_dist_up;
+        *best_rs_cost = best_rs_cost_up;
+        best_corners->tl_x = best_corners_up.tl_x * inv;
+        best_corners->tl_y = best_corners_up.tl_y * inv;
+        best_corners->tr_x = best_corners_up.tr_x * inv;
+        best_corners->tr_y = best_corners_up.tr_y * inv;
+        best_corners->br_x = best_corners_up.br_x * inv;
+        best_corners->br_y = best_corners_up.br_y * inv;
+        best_corners->bl_x = best_corners_up.bl_x * inv;
+        best_corners->bl_y = best_corners_up.bl_y * inv;
+        for (int i = 0; i < 3; i++) {
+          best_finders[i].x = best_finders_up[i].x * inv;
+          best_finders[i].y = best_finders_up[i].y * inv;
+        }
+        *best_module_size = best_module_size_up * inv;
+        *best_version = best_version_up;
+        *best_qr_size = best_qr_size_up;
+        *best_alignment_count = best_alignment_count_up;
+        for (int i = 0; i < best_alignment_count_up; i++) {
+          best_alignments[i].x = best_alignments_up[i].x * inv;
+          best_alignments[i].y = best_alignments_up[i].y * inv;
+        }
+      }
+      else {
+        free(best_decoded_up);
+      }
+    }
+  }
+
+  /* Attempt 6: 3x3 median filter + Otsu, then median + adaptive. Median
+     denoising preserves edges while removing salt-and-pepper noise that
+     defeats both finder-ratio checks and module-center voting. Especially
+     helpful for level_5 images (sigma 4-9 noise + low contrast). Runs
+     when nothing decoded OR the existing decode is suspiciously short
+     (a count-field truncation of byte mode); a long decode from earlier
+     is trusted and we don't risk displacing it. */
+  int existing_short = *best_decoded && strlen(*best_decoded) < 8;
+  if (!small_image && (!*best_decoded || existing_short)) {
+    uint8_t *med = median_filter_3x3(gray_pixels, w, h);
+    if (med) {
+      bin = binarize_global_otsu(med, w, h);
+      if (bin) {
+        try_pipeline(
+          bin,
+          med,
+          w,
+          h,
+          best_decoded,
+          best_fmt_dist,
+          best_rs_cost,
+          best_corners,
+          best_finders,
+          best_module_size,
+          best_version,
+          best_qr_size,
+          best_alignments,
+          best_alignment_count
+        );
+        free(bin);
+      }
+      if (!(*best_decoded && *best_fmt_dist <= 1 && strlen(*best_decoded) >= 5
+          )) {
+        bin = binarize_adaptive(med, w, h);
+        if (bin) {
+          try_pipeline(
+            bin,
+            med,
+            w,
+            h,
+            best_decoded,
+            best_fmt_dist,
+            best_rs_cost,
+            best_corners,
+            best_finders,
+            best_module_size,
+            best_version,
+            best_qr_size,
+            best_alignments,
+            best_alignment_count
+          );
+          free(bin);
+        }
+      }
+      free(med);
+    }
+  }
+}
+
+/* ---- Pyramid-level decode helpers ---- */
+
+/* Scales all spatial fields of a decode state by `scale`. Used to convert
+   coordinates from a downsampled pyramid level back into original-image
+   pixel space. */
+static void scale_decode_state(
+  Corners *corners,
+  Point2D finders[3],
+  double *module_size,
+  Point2D alignments[QR_MAX_ALIGNMENTS],
+  int alignment_count,
+  double scale
+) {
+  corners->tl_x *= scale;
+  corners->tl_y *= scale;
+  corners->tr_x *= scale;
+  corners->tr_y *= scale;
+  corners->br_x *= scale;
+  corners->br_y *= scale;
+  corners->bl_x *= scale;
+  corners->bl_y *= scale;
+  for (int i = 0; i < 3; i++) {
+    finders[i].x *= scale;
+    finders[i].y *= scale;
+  }
+  for (int i = 0; i < alignment_count; i++) {
+    alignments[i].x *= scale;
+    alignments[i].y *= scale;
+  }
+  *module_size *= scale;
+}
+
+/* Returns 1 if (cand_*) should replace (cur_*). Ranking is: lower fmt_dist
+   first, then longer decoded string, then lower RS cost. Matches the
+   promotion logic previously inlined in the upsample / downsample blocks. */
+static int decode_is_better(
+  char const *cur,
+  int cur_fmt,
+  int cur_rs,
+  char const *cand,
+  int cand_fmt,
+  int cand_rs
+) {
+  if (!cand) {
+    return 0;
+  }
+  if (!cur) {
+    return 1;
+  }
+  if (cand_fmt != cur_fmt) {
+    return cand_fmt < cur_fmt;
+  }
+  int cur_len = (int)strlen(cur);
+  int cand_len = (int)strlen(cand);
+  if (cand_len != cur_len) {
+    return cand_len > cur_len;
+  }
+  return cand_rs < cur_rs;
+}
+
+FCVQRCodeResult fcv_decode_qr_codes(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const gray_pixels
+) {
+  FCVQRCodeResult result;
+  result.codes = NULL;
+  result.count = 0;
+
+  if (!gray_pixels || width == 0 || height == 0) {
+    return result;
+  }
+  if (width > INT32_MAX || height > INT32_MAX) {
+    return result;
+  }
+
+  result.codes = malloc(MAX_QR_CODES * sizeof(FCVQRCode));
+  if (!result.codes) {
+    return result;
+  }
+
+  int w = (int)width;
+  int h = (int)height;
+
+  /* ---- Build a box-averaged pyramid ----
+     Level 0 is the original; level k+1 is a 2x2 box-average of level k.
+     We stop halving once the short side would drop below MIN_SHORT_SIDE,
+     below which even a version-1 QR (21 modules) can't plausibly survive
+     binarization (<6 px/module after quiet zone). */
+#define QR_PYRAMID_MAX 6
+#define QR_PYRAMID_MIN_SHORT 120
+#define QR_PYRAMID_TARGET_SHORT 640
+
+  uint8_t const *lv_px[QR_PYRAMID_MAX];
+  int lv_w[QR_PYRAMID_MAX], lv_h[QR_PYRAMID_MAX];
+  int lv_owned[QR_PYRAMID_MAX];
+
+  lv_px[0] = gray_pixels;
+  lv_w[0] = w;
+  lv_h[0] = h;
+  lv_owned[0] = 0;
+  int n_levels = 1;
+  while (n_levels < QR_PYRAMID_MAX) {
+    int nw = lv_w[n_levels - 1] / 2;
+    int nh = lv_h[n_levels - 1] / 2;
+    int short_side = nw < nh ? nw : nh;
+    if (short_side < QR_PYRAMID_MIN_SHORT) {
+      break;
+    }
+    uint8_t *down = downsample_2x_box(
+      lv_px[n_levels - 1],
+      lv_w[n_levels - 1],
+      lv_h[n_levels - 1]
+    );
+    if (!down) {
+      break;
+    }
+    lv_px[n_levels] = down;
+    lv_w[n_levels] = nw;
+    lv_h[n_levels] = nh;
+    lv_owned[n_levels] = 1;
+    n_levels++;
+  }
+
+  /* Target level: the coarsest (highest-index) level whose short side is
+     still >= TARGET. Decouples first-pass work from sensor resolution:
+     the common case of "one QR filling a large fraction of a phone photo"
+     gets scanned at ~640 px regardless of whether the source is 1 MP or
+     48 MP. If no level meets the target (small input), target stays 0. */
+  int target = 0;
+  for (int i = 1; i < n_levels; i++) {
+    int short_side = lv_w[i] < lv_h[i] ? lv_w[i] : lv_h[i];
+    if (short_side >= QR_PYRAMID_TARGET_SHORT) {
+      target = i;
+    }
+    else {
+      break;
+    }
+  }
+
+  char *best_decoded = NULL;
+  Corners best_corners = {0};
+  Point2D best_finders[3] = {{0, 0}, {0, 0}, {0, 0}};
+  double best_module_size = 0;
+  int best_fmt_dist = 16;
+  int best_rs_cost = 100000;
+  int best_version = 0;
+  int best_qr_size = 0;
+  Point2D best_alignments[QR_MAX_ALIGNMENTS];
+  int best_alignment_count = 0;
+
+  /* Visit order: target first (best balance of detail vs. noise for the
+     typical "one QR filling most of a phone photo" case), then coarser
+     levels (cheap, different binarizer response — catches level_13's
+     low-contrast pastel-on-checkered case the old inline 2x-downsample
+     attempt used to handle), then finer levels (expensive, last resort
+     for tiny QRs). Stop as soon as a confident decode (fmt_dist <= 1,
+     length >= 5) is in hand. Non-confident decodes are kept as best-so-
+     far and may be displaced by a cleaner result from another level. */
+  int visit_order[QR_PYRAMID_MAX];
+  int n_visits = 0;
+  visit_order[n_visits++] = target;
+  for (int lvl = target + 1; lvl < n_levels; lvl++) {
+    visit_order[n_visits++] = lvl;
+  }
+  for (int lvl = target - 1; lvl >= 0; lvl--) {
+    visit_order[n_visits++] = lvl;
+  }
+
+  for (int vi = 0; vi < n_visits; vi++) {
+    int lvl = visit_order[vi];
+    double scale = (double)(1 << lvl);
+    int is_finest = (lvl == 0);
+    int lw = lv_w[lvl];
+    int lh = lv_h[lvl];
+    uint8_t const *lpx = lv_px[lvl];
+
+    char *decoded = NULL;
+    Corners corners = {0};
+    Point2D finders[3] = {{0, 0}, {0, 0}, {0, 0}};
+    double module_size = 0;
+    int fmt_dist = 16;
+    int rs_cost = 100000;
+    int version = 0;
+    int qr_size = 0;
+    Point2D alignments[QR_MAX_ALIGNMENTS];
+    int alignment_count = 0;
+
+    run_all_attempts(
+      lpx,
+      lw,
+      lh,
+      is_finest,
+      &decoded,
+      &fmt_dist,
+      &rs_cost,
+      &corners,
+      finders,
+      &module_size,
+      &version,
+      &qr_size,
+      alignments,
+      &alignment_count
+    );
+
+    /* Inverted-polarity retry at this level — handles light-on-dark QRs.
+       Only fires if nothing at this level has decoded confidently.
+       Confidence = fmt_dist <= 1 (format info BCH can correct 1-bit errors
+       in its 15-bit code, so a 0-or-1-bit fmt_dist reliably indicates a
+       real QR). Length is not a trust signal: legitimate v1 QRs routinely
+       encode 2-4 characters. */
+    if (!(decoded && fmt_dist <= 1)) {
+      uint8_t *inv = invert_gray(lpx, lw, lh);
+      if (inv) {
+        run_all_attempts(
+          inv,
+          lw,
+          lh,
+          is_finest,
+          &decoded,
+          &fmt_dist,
+          &rs_cost,
+          &corners,
+          finders,
+          &module_size,
+          &version,
+          &qr_size,
+          alignments,
+          &alignment_count
+        );
+        free(inv);
+      }
+    }
+
+    if (decoded) {
+      scale_decode_state(
+        &corners,
+        finders,
+        &module_size,
+        alignments,
+        alignment_count,
+        scale
+      );
+      if (decode_is_better(
+            best_decoded,
+            best_fmt_dist,
+            best_rs_cost,
+            decoded,
+            fmt_dist,
+            rs_cost
+          )) {
+        free(best_decoded);
+        best_decoded = decoded;
+        best_fmt_dist = fmt_dist;
+        best_rs_cost = rs_cost;
+        best_corners = corners;
+        for (int i = 0; i < 3; i++) {
+          best_finders[i] = finders[i];
+        }
+        best_module_size = module_size;
+        best_version = version;
+        best_qr_size = qr_size;
+        best_alignment_count = alignment_count;
+        for (int i = 0; i < alignment_count; i++) {
+          best_alignments[i] = alignments[i];
+        }
+      }
+      else {
+        free(decoded);
+      }
+    }
+
+    if (best_decoded && best_fmt_dist <= 1) {
+      break;
+    }
+  }
+
+  for (int i = 1; i < n_levels; i++) {
+    if (lv_owned[i]) {
+      free((uint8_t *)lv_px[i]);
+    }
+  }
+
+#undef QR_PYRAMID_MAX
+#undef QR_PYRAMID_MIN_SHORT
+#undef QR_PYRAMID_TARGET_SHORT
+
+  if (best_decoded) {
+    FCVQRCode *qr = &result.codes[result.count++];
+    qr->text = best_decoded;
+    qr->corners = best_corners;
+    for (int i = 0; i < 3; i++) {
+      qr->finders[i] = best_finders[i];
+    }
+    qr->module_size = best_module_size;
+    qr->version = best_version;
+    qr->qr_size = best_qr_size;
+    qr->alignment_count = (size_t)best_alignment_count;
+    if (best_alignment_count > 0) {
+      qr->alignments = malloc((size_t)best_alignment_count * sizeof(Point2D));
+      if (qr->alignments) {
+        for (int i = 0; i < best_alignment_count; i++) {
+          qr->alignments[i] = best_alignments[i];
+        }
+      }
+      else {
+        qr->alignment_count = 0;
+      }
+    }
+    else {
+      qr->alignments = NULL;
+    }
+  }
+
+  return result;
+}
+
+void fcv_free_qr_result(FCVQRCodeResult result) {
+  if (result.codes) {
+    for (size_t i = 0; i < result.count; i++) {
+      free(result.codes[i].text);
+      free(result.codes[i].alignments);
+    }
+    free(result.codes);
+  }
+}
+// File: src/rgba_to_grayscale.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "rgba_to_grayscale.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Convert raw RGBA row-major top-to-bottom image data
+ * to a single channel grayscale image data.
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param data Pointer to the pixel data.
+ * @return Pointer to the single channel grayscale image data.
+ */
+uint8_t *fcv_rgba_to_grayscale(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const data
+) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t img_length_px = (size_t)width * height;
+
+  uint8_t *grayscale_data = malloc(img_length_px);
+
+  if (!grayscale_data) { // Memory allocation failed
+    return NULL;
+  }
+
+  // Process each pixel row by row
+  for (size_t i = 0; i < img_length_px; i++) {
+    size_t rgba_index = i * 4;
+
+    uint8_t r = data[rgba_index];
+    uint8_t g = data[rgba_index + 1];
+    uint8_t b = data[rgba_index + 2];
+
+    uint8_t gray = (r * R_WEIGHT + g * G_WEIGHT + b * B_WEIGHT) >> 8;
+
+    grayscale_data[i] = gray;
+  }
+
+  return grayscale_data;
+}
+// File: src/rotate.c
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "rotate.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Rotate an image 90 degrees clockwise.
+ */
+uint8_t *
+fcv_rotate_90_cw(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *rotated_data = malloc(img_length_byte);
+
+  if (!rotated_data) {
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index = ((size_t)x * height + (height - 1 - y)) * 4;
+
+      rotated_data[dst_index] = data[src_index];
+      rotated_data[dst_index + 1] = data[src_index + 1];
+      rotated_data[dst_index + 2] = data[src_index + 2];
+      rotated_data[dst_index + 3] = data[src_index + 3];
+    }
+  }
+
+  return rotated_data;
+}
+
+/**
+ * Rotate an image 180 degrees.
+ */
+uint8_t *
+fcv_rotate_180(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *rotated_data = malloc(img_length_byte);
+
+  if (!rotated_data) {
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index =
+        ((size_t)(height - 1 - y) * width + (width - 1 - x)) * 4;
+
+      rotated_data[dst_index] = data[src_index];
+      rotated_data[dst_index + 1] = data[src_index + 1];
+      rotated_data[dst_index + 2] = data[src_index + 2];
+      rotated_data[dst_index + 3] = data[src_index + 3];
+    }
+  }
+
+  return rotated_data;
+}
+
+/**
+ * Rotate an image 270 degrees clockwise (90 degrees counter-clockwise).
+ */
+uint8_t *
+fcv_rotate_270_cw(uint32_t width, uint32_t height, uint8_t const *const data) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t num_pixels = (size_t)width * height;
+  if (num_pixels > SIZE_MAX / 4) {
+    return NULL;
+  }
+  size_t img_length_byte = num_pixels * 4;
+
+  uint8_t *rotated_data = malloc(img_length_byte);
+
+  if (!rotated_data) {
+    return NULL;
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      size_t src_index = ((size_t)y * width + x) * 4;
+      size_t dst_index = ((size_t)(width - 1 - x) * height + y) * 4;
+
+      rotated_data[dst_index] = data[src_index];
+      rotated_data[dst_index + 1] = data[src_index + 1];
+      rotated_data[dst_index + 2] = data[src_index + 2];
+      rotated_data[dst_index + 3] = data[src_index + 3];
+    }
+  }
+
+  return rotated_data;
+}
+// File: src/single_to_multichannel.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "single_to_multichannel.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Convert single channel grayscale image data to
+ * RGBA row-major top-to-bottom image data.
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param data Pointer to the pixel data.
+ */
+uint8_t *fcv_single_to_multichannel(
+  uint32_t width,
+  uint32_t height,
+  uint8_t const *const data
+) {
+  if (!data || width == 0 || height == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height * 4
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t img_length_px = (size_t)width * height;
+  if (img_length_px > SIZE_MAX / 4) {
+    return NULL;
+  }
+
+  uint8_t *multichannel_data = malloc(img_length_px * 4);
+
+  if (!multichannel_data) { // Memory allocation failed
+    return NULL;
+  }
+
+  for (size_t i = 0; i < img_length_px; i++) {
+    size_t rgba_index = i * 4;
+    multichannel_data[rgba_index] = data[i];
+    multichannel_data[rgba_index + 1] = data[i];
+    multichannel_data[rgba_index + 2] = data[i];
+    multichannel_data[rgba_index + 3] = 255;
+  }
+
+  return multichannel_data;
+}
+// File: src/sobel_edge_detection.c
+#include <assert.h>
+#include <math.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "conversion.h"
+#include "perspectivetransform.h"
+#include "rgba_to_grayscale.h"
+#include "sobel_edge_detection.h"
+#else
+#include "flatcv.h"
+#endif
+
+/**
+ * Apply Sobel edge detection to the image data and return single-channel
+ * grayscale data. Uses Sobel kernels to detect edges in horizontal and vertical
+ * directions, then combines them to get the edge magnitude.
+ *
+ * @param width Width of the image.
+ * @param height Height of the image.
+ * @param channels Number of channels in the input image.
+ *                 (1: Grayscale, 3: RGB, 4: RGBA)
+ * @param data Pointer to the input pixel data.
+ * @return Pointer to the single-channel grayscale edge-detected image data.
+ */
+uint8_t *fcv_sobel_edge_detection(
+  uint32_t width,
+  uint32_t height,
+  uint32_t channels,
+  uint8_t const *const data
+) {
+  if (!data || width == 0 || height == 0 || channels == 0) {
+    return NULL;
+  }
+
+  // Check for overflow: width * height
+  if (width > SIZE_MAX / height) {
+    return NULL;
+  }
+  size_t img_length_px = (size_t)width * height;
+
+  // Check for overflow in magnitudes allocation
+  if (img_length_px > SIZE_MAX / sizeof(double)) {
+    return NULL;
+  }
+
+  uint8_t *grayscale_data;
+  bool allocated_grayscale = false;
+
+  if (channels == 1) {
+    // Single-channel input, use data directly
+    grayscale_data = (uint8_t *)data;
+    allocated_grayscale = false;
+  }
+  else {
+    // Multi-channel input, convert to grayscale
+    grayscale_data = fcv_rgba_to_grayscale(width, height, data);
+    if (!grayscale_data) {
+      return NULL;
+    }
+    allocated_grayscale = true;
+  }
+
+  uint8_t *sobel_data = malloc(img_length_px);
+  if (!sobel_data) {
+    if (allocated_grayscale) {
+      free(grayscale_data);
+    }
+    return NULL;
+  }
+
+  // Sobel kernels
+  int32_t sobel_x[3][3] = {{-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1}};
+  int32_t sobel_y[3][3] = {{-1, -2, -1}, {0, 0, 0}, {1, 2, 1}};
+
+  // Temporary buffer to store magnitudes for normalization
+  double *magnitudes = malloc(img_length_px * sizeof(double));
+  if (!magnitudes) {
+    if (allocated_grayscale) {
+      free(grayscale_data);
+    }
+    free(sobel_data);
+    return NULL;
+  }
+
+  double min_magnitude = INFINITY;
+  double max_magnitude = 0.0;
+
+  // First pass: calculate all magnitudes and find min/max
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      int32_t gx = 0;
+      int32_t gy = 0;
+
+      // Apply Sobel kernels
+      for (int32_t ky = -1; ky <= 1; ky++) {
+        for (int32_t kx = -1; kx <= 1; kx++) {
+          int32_t px = x + kx;
+          int32_t py = y + ky;
+
+          // Handle boundaries by using nearest pixel values
+          if (px < 0) {
+            px = 0;
+          }
+          if ((uint32_t)px >= width) {
+            px = width - 1;
+          }
+          if (py < 0) {
+            py = 0;
+          }
+          if ((uint32_t)py >= height) {
+            py = height - 1;
+          }
+
+          uint8_t pixel = grayscale_data[(size_t)py * width + px];
+          gx += pixel * sobel_x[ky + 1][kx + 1];
+          gy += pixel * sobel_y[ky + 1][kx + 1];
+        }
+      }
+
+      double magnitude = sqrt((double)gx * gx + (double)gy * gy);
+      magnitudes[(size_t)y * width + x] = magnitude;
+
+      if (magnitude < min_magnitude) {
+        min_magnitude = magnitude;
+      }
+      if (magnitude > max_magnitude) {
+        max_magnitude = magnitude;
+      }
+    }
+  }
+
+  // Second pass: normalize and assign to output
+  double range = max_magnitude - min_magnitude;
+  if (range == 0.0) {
+    range = 1.0; // Avoid division by zero
+  }
+
+  for (uint32_t y = 0; y < height; y++) {
+    for (uint32_t x = 0; x < width; x++) {
+      double magnitude = magnitudes[(size_t)y * width + x];
+
+      // Normalize to 0-255 range based on actual min/max
+      int32_t final_magnitude =
+        (int32_t)(((magnitude - min_magnitude) / range) * 255.0);
+
+      // Clamp to valid range
+      if (final_magnitude < 0) {
+        final_magnitude = 0;
+      }
+      if (final_magnitude > 255) {
+        final_magnitude = 255;
+      }
+
+      sobel_data[(size_t)y * width + x] = (uint8_t)final_magnitude;
+    }
+  }
+
+  free(magnitudes);
+
+  if (allocated_grayscale) {
+    free(grayscale_data);
+  }
+  return sobel_data;
+}
+// File: src/sort_corners.c
+#include <math.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#ifndef FLATCV_AMALGAMATION
+#include "1_types.h"
+#else
+#include "flatcv.h"
+#endif
+
+typedef struct {
+  double r;     // radius
+  double theta; // angle
+} PolarPoint;
+
+// Helper function to convert Cartesian to polar coordinates
+PolarPoint cartesian_to_polar(Point2D cart) {
+  PolarPoint polar;
+  polar.r = sqrt(cart.x * cart.x + cart.y * cart.y);
+  double theta = atan2(cart.y, cart.x);
+
+  // Normalize theta: Convert to degrees and apply normalization
+  double theta_deg = theta * 180.0 / M_PI;
+  if (theta_deg < 0) {
+    polar.theta = -theta_deg;
+  }
+  else {
+    polar.theta = -(theta_deg - 360.0);
+  }
+
+  return polar;
+}
+
+// Comparison function for qsort (sorting by angle in descending order)
+int compare_polar_angles(const void *a, const void *b) {
+  const PolarPoint *pa = (const PolarPoint *)a;
+  const PolarPoint *pb = (const PolarPoint *)b;
+
+  if (pa->theta < pb->theta) {
+    return 1; // Descending order
+  }
+  if (pa->theta > pb->theta) {
+    return -1;
+  }
+  return 0;
+}
+
+// Helper structure to keep track of original indices during sorting
+typedef struct {
+  PolarPoint polar;
+  uint32_t original_index;
+} IndexedPolarPoint;
+
+int compare_indexed_polar_angles(const void *a, const void *b) {
+  const IndexedPolarPoint *pa = (const IndexedPolarPoint *)a;
+  const IndexedPolarPoint *pb = (const IndexedPolarPoint *)b;
+
+  if (pa->polar.theta < pb->polar.theta) {
+    return 1; // Descending order
+  }
+  if (pa->polar.theta > pb->polar.theta) {
+    return -1;
+  }
+  return 0;
+}
+
+Corners sort_corners(
+  uint32_t width,
+  uint32_t height,
+  uint32_t out_width,
+  uint32_t out_height,
+  Point2D *corners,
+  uint32_t num_corners,
+  Point2D *result
+) {
+  if (num_corners < 3) {
+    // Not enough corners, return zeros
+    Corners empty_corners = {0, 0, 0, 0, 0, 0, 0, 0};
+    return empty_corners;
+  }
+
+  // Allocate memory for working corners (max of num_corners or 4 for 3-corner
+  // case)
+  uint32_t max_corners = (num_corners >= 4) ? num_corners : 4;
+  Point2D *working_corners = malloc(max_corners * sizeof(Point2D));
+  if (!working_corners) {
+    Corners empty_corners = {0, 0, 0, 0, 0, 0, 0, 0};
+    return empty_corners;
+  }
+
+  uint32_t corners_to_process;
+
+  if (num_corners == 3) {
+    // Copy the 3 corners we have
+    working_corners[0] = corners[0];
+    working_corners[1] = corners[1];
+    working_corners[2] = corners[2];
+
+    // Calculate the fourth corner to complete a parallelogram
+    // For 3 corners A, B, C, we need to determine which corner is missing
+    // and calculate it using vector addition
+
+    // First, sort the 3 corners by position to understand the layout
+    Point2D sorted[3] = {corners[0], corners[1], corners[2]};
+
+    // Find bounding box of the 3 corners
+    double min_x = sorted[0].x, max_x = sorted[0].x;
+    double min_y = sorted[0].y, max_y = sorted[0].y;
+    for (int i = 1; i < 3; i++) {
+      if (sorted[i].x < min_x) {
+        min_x = sorted[i].x;
+      }
+      if (sorted[i].x > max_x) {
+        max_x = sorted[i].x;
+      }
+      if (sorted[i].y < min_y) {
+        min_y = sorted[i].y;
+      }
+      if (sorted[i].y > max_y) {
+        max_y = sorted[i].y;
+      }
+    }
+
+    // Determine which corner is missing by finding which position in the
+    // rectangle is empty
+    bool has_tl = false, has_tr = false, has_bl = false, has_br = false;
+    for (int i = 0; i < 3; i++) {
+      double x = sorted[i].x;
+      double y = sorted[i].y;
+
+      // Use small tolerance for floating point comparison
+      double tolerance = 1.0;
+
+      if (fabs(x - min_x) < tolerance && fabs(y - min_y) < tolerance) {
+        has_tl = true;
+      }
+      else if (fabs(x - max_x) < tolerance && fabs(y - min_y) < tolerance) {
+        has_tr = true;
+      }
+      else if (fabs(x - min_x) < tolerance && fabs(y - max_y) < tolerance) {
+        has_bl = true;
+      }
+      else if (fabs(x - max_x) < tolerance && fabs(y - max_y) < tolerance) {
+        has_br = true;
+      }
+    }
+
+    // Calculate the missing corner
+    Point2D missing_corner;
+    if (!has_tl) {
+      missing_corner.x = min_x;
+      missing_corner.y = min_y;
+    }
+    else if (!has_tr) {
+      missing_corner.x = max_x;
+      missing_corner.y = min_y;
+    }
+    else if (!has_bl) {
+      missing_corner.x = min_x;
+      missing_corner.y = max_y;
+    }
+    else if (!has_br) {
+      missing_corner.x = max_x;
+      missing_corner.y = max_y;
+    }
+    else {
+      // Fallback: use parallelogram completion
+      // For a parallelogram ABCD where we have A, B, C and need D:
+      // D = A + C - B (assuming B and D are diagonal)
+      // Try all combinations and pick the best one
+      Point2D candidates[3];
+      candidates[0].x = corners[0].x + corners[1].x - corners[2].x;
+      candidates[0].y = corners[0].y + corners[1].y - corners[2].y;
+      candidates[1].x = corners[0].x + corners[2].x - corners[1].x;
+      candidates[1].y = corners[0].y + corners[2].y - corners[1].y;
+      candidates[2].x = corners[1].x + corners[2].x - corners[0].x;
+      candidates[2].y = corners[1].y + corners[2].y - corners[0].y;
+
+      // Choose the candidate that gives the most reasonable result
+      missing_corner = candidates[2]; // Default to the last one
+      double best_score = -1;
+
+      for (int i = 0; i < 3; i++) {
+        double score = 0;
+        if (candidates[i].x >= 0) {
+          score += 1;
+        }
+        if (candidates[i].y >= 0) {
+          score += 1;
+        }
+
+        // Check if it's not a duplicate
+        bool is_duplicate = false;
+        for (int j = 0; j < 3; j++) {
+          if (fabs(candidates[i].x - corners[j].x) < 0.1 &&
+              fabs(candidates[i].y - corners[j].y) < 0.1) {
+            is_duplicate = true;
+            break;
+          }
+        }
+        if (!is_duplicate) {
+          score += 5;
+        }
+
+        if (score > best_score) {
+          best_score = score;
+          missing_corner = candidates[i];
+        }
+      }
+    }
+
+    working_corners[3] = missing_corner;
+    corners_to_process = 4;
+  }
+  else {
+    // Copy all existing corners
+    for (uint32_t i = 0; i < num_corners; i++) {
+      working_corners[i] = corners[i];
+    }
+    corners_to_process = num_corners;
+  }
+
+  // Clockwise corner sorting starting from top-left corner
+  // Works with any number of corners >= 3
+
+  // Step 1: Find the center point (centroid)
+  double center_x = 0.0, center_y = 0.0;
+  for (uint32_t i = 0; i < corners_to_process; i++) {
+    center_x += working_corners[i].x;
+    center_y += working_corners[i].y;
+  }
+  center_x /= corners_to_process;
+  center_y /= corners_to_process;
+
+  // Step 2: Find top-left in document-relative coordinates. A plain x+y
+  // score lets the long axis dominate on skewed receipts, choosing bottom-left
+  // (or top-right) instead. Normalize by the document bounds, not image bounds,
+  // so the labels also survive the detector's non-uniform resize to 256x256.
+  double min_x = working_corners[0].x, max_x = min_x;
+  double min_y = working_corners[0].y, max_y = min_y;
+  for (uint32_t i = 1; i < corners_to_process; i++) {
+    min_x = fmin(min_x, working_corners[i].x);
+    max_x = fmax(max_x, working_corners[i].x);
+    min_y = fmin(min_y, working_corners[i].y);
+    max_y = fmax(max_y, working_corners[i].y);
+  }
+  double span_x = max_x > min_x ? max_x - min_x : 1.0;
+  double span_y = max_y > min_y ? max_y - min_y : 1.0;
+  uint32_t top_left_idx = 0;
+  double min_sum = (working_corners[0].x - min_x) / span_x +
+                   (working_corners[0].y - min_y) / span_y;
+  for (uint32_t i = 1; i < corners_to_process; i++) {
+    double sum = (working_corners[i].x - min_x) / span_x +
+                 (working_corners[i].y - min_y) / span_y;
     if (sum < min_sum) {
       min_sum = sum;
       top_left_idx = i;
diff --git a/src/FlatCV.hs b/src/FlatCV.hs
--- a/src/FlatCV.hs
+++ b/src/FlatCV.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards #-}
 
 -- |
@@ -33,6 +34,7 @@
   , Corners(..)
   , Matrix3x3(..)
   , Point2D(..)
+  , Connectivity(..)
 
     -- * Pretty printing
   , prettyShowCorners
@@ -96,6 +98,7 @@
 
     -- * Segmentation
   , watershedSegmentation
+  , floodFill
 
     -- * EXIF
   , getExifOrientation
@@ -139,6 +142,8 @@
 
 import Control.Monad (when)
 import Data.Int (Int32)
+import Data.Text (Text)
+import qualified Data.Text as T
 import Data.Word (Word8, Word32)
 import qualified Data.Vector.Storable as V
 import qualified Data.Vector.Storable.Mutable as MV
@@ -146,7 +151,7 @@
 import Foreign.C.Types (CUChar)
 import Foreign.ForeignPtr (newForeignPtr, FinalizerPtr, castForeignPtr)
 import Foreign.Marshal.Alloc (alloca, free, mallocBytes)
-import Foreign.Marshal.Array (pokeArray)
+import Foreign.Marshal.Array (pokeArray, withArrayLen)
 import Foreign.Ptr (Ptr, castPtr, nullPtr)
 import Foreign.Storable (Storable(..), peek, poke, sizeOf, alignment)
 import Text.Printf (printf)
@@ -178,6 +183,12 @@
     pokeByteOff ptr 0 pointX
     pokeByteOff ptr 8 pointY
 
+-- | Pixel neighborhood used for region growing
+data Connectivity
+  = Connectivity4  -- ^ Edge neighbors only
+  | Connectivity8  -- ^ Edge and diagonal neighbors
+  deriving (Show, Eq)
+
 -- | Quadrilateral corners (top-left, top-right, bottom-right, bottom-left)
 data Corners = Corners
   { tlX :: {-# UNPACK #-} !Double
@@ -214,10 +225,10 @@
     pokeByteOff ptr 56 blY
 
 -- | Pretty print corners for debugging
-prettyShowCorners :: Corners -> String
+prettyShowCorners :: Corners -> Text
 prettyShowCorners Corners{..} =
-  show (tlX, tlY) ++ " " ++ show (trX, trY) ++ "\n" ++
-  show (blX, blY) ++ " " ++ show (brX, brY)
+  T.pack (show (tlX, tlY)) <> " " <> T.pack (show (trX, trY)) <> "\n" <>
+  T.pack (show (blX, blY)) <> " " <> T.pack (show (brX, brY))
 
 -- | 3x3 transformation matrix
 data Matrix3x3 = Matrix3x3
@@ -258,12 +269,12 @@
     pokeByteOff ptr 64 m22
 
 -- | Pretty print 3x3 matrix for debugging
-prettyShowMatrix3x3 :: Matrix3x3 -> String
+prettyShowMatrix3x3 :: Matrix3x3 -> Text
 prettyShowMatrix3x3 Matrix3x3{..} =
-  let fNum n = printf "% .5f" (n :: Double)
-  in fNum m00 ++ " " ++ fNum m01 ++ " " ++ fNum m02 ++ "\n" ++
-     fNum m10 ++ " " ++ fNum m11 ++ " " ++ fNum m12 ++ "\n" ++
-     fNum m20 ++ " " ++ fNum m21 ++ " " ++ fNum m22
+  let fNum n = T.pack (printf "% .5f" (n :: Double))
+  in fNum m00 <> " " <> fNum m01 <> " " <> fNum m02 <> "\n" <>
+     fNum m10 <> " " <> fNum m11 <> " " <> fNum m12 <> "\n" <>
+     fNum m20 <> " " <> fNum m21 <> " " <> fNum m22
 
 -- Foreign finalizer for C-allocated memory
 foreign import ccall "stdlib.h &free" finalizerFree :: FinalizerPtr CUChar
@@ -539,31 +550,31 @@
 --------------------------------------------------------------------------------
 
 -- | Draw a circle outline on an image (modifies input in place)
-drawCircle :: Word32 -> Word32 -> Word32 -> String -> Double -> Double -> Double -> V.Vector Word8 -> IO (V.Vector Word8)
+drawCircle :: Word32 -> Word32 -> Word32 -> Text -> Double -> Double -> Double -> V.Vector Word8 -> IO (V.Vector Word8)
 drawCircle width height channels hexColor radius centerX centerY input = do
   output <- V.thaw input
   MV.unsafeWith output $ \ptr ->
-    withCString hexColor $ \colorPtr ->
+    withCString (T.unpack hexColor) $ \colorPtr ->
       Raw.fcv_draw_circle width height channels colorPtr radius centerX centerY ptr
   V.unsafeFreeze output
 
 -- | Draw a filled disk on an image (modifies input in place)
-drawDisk :: Word32 -> Word32 -> Word32 -> String -> Double -> Double -> Double -> V.Vector Word8 -> IO (V.Vector Word8)
+drawDisk :: Word32 -> Word32 -> Word32 -> Text -> Double -> Double -> Double -> V.Vector Word8 -> IO (V.Vector Word8)
 drawDisk width height channels hexColor radius centerX centerY input = do
   output <- V.thaw input
   MV.unsafeWith output $ \ptr ->
-    withCString hexColor $ \colorPtr ->
+    withCString (T.unpack hexColor) $ \colorPtr ->
       Raw.fcv_draw_disk width height channels colorPtr radius centerX centerY ptr
   V.unsafeFreeze output
 
 -- | Add a border around an image
 -- Returns (output width, output height, output data)
-addBorder :: Word32 -> Word32 -> Word32 -> String -> Word32 -> V.Vector Word8 -> IO (Word32, Word32, V.Vector Word8)
+addBorder :: Word32 -> Word32 -> Word32 -> Text -> Word32 -> V.Vector Word8 -> IO (Word32, Word32, V.Vector Word8)
 addBorder width height channels hexColor borderWidth input = do
   alloca $ \outWPtr ->
     alloca $ \outHPtr -> do
       V.unsafeWith input $ \inPtr ->
-        withCString hexColor $ \colorPtr -> do
+        withCString (T.unpack hexColor) $ \colorPtr -> do
           outPtr <- Raw.fcv_add_border width height channels colorPtr borderWidth inPtr outWPtr outHPtr
           when (outPtr == nullPtr) $ error "FlatCV: add_border failed"
           outW <- peek outWPtr
@@ -606,6 +617,25 @@
   free markersPtr
   return result
 
+-- | Flood fill segmentation on single-channel grayscale data.
+-- Starting from each marker, all connected pixels whose value differs
+-- by at most @threshold@ from that marker's value are set to @newValue@.
+-- Each marker is filled independently, the result is the union of all fills.
+-- Fails if no markers are given or a marker lies outside the image.
+floodFill
+  :: Word32 -> Word32 -> [Point2D] -> Word8 -> Word8 -> Connectivity
+  -> V.Vector Word8 -> IO (V.Vector Word8)
+floodFill width height markers threshold newValue connectivity input =
+  withArrayLen markers $ \numMarkers markersPtr ->
+    withImageData input
+      (\p -> Raw.fcv_flood_fill width height p (castPtr markersPtr)
+        (fromIntegral numMarkers) threshold newValue connectivityNum)
+      (width * height)
+  where
+    connectivityNum = case connectivity of
+      Connectivity4 -> 4
+      Connectivity8 -> 8
+
 --------------------------------------------------------------------------------
 -- EXIF
 --------------------------------------------------------------------------------
@@ -621,12 +651,12 @@
 --------------------------------------------------------------------------------
 
 -- | Parse hex color string (e.g., "#FF0000" or "FF0000") to RGB
-parseHexColor :: String -> IO (Word8, Word8, Word8)
+parseHexColor :: Text -> IO (Word8, Word8, Word8)
 parseHexColor hexColor =
   alloca $ \rPtr ->
     alloca $ \gPtr ->
       alloca $ \bPtr ->
-        withCString hexColor $ \colorPtr -> do
+        withCString (T.unpack hexColor) $ \colorPtr -> do
           Raw.fcv_parse_hex_color colorPtr rPtr gPtr bPtr
           r <- peek rPtr
           g <- peek gPtr
@@ -634,10 +664,10 @@
           return (r, g, b)
 
 -- | Convert image to binary with custom foreground and background colors
-convertToBinary :: Int32 -> Int32 -> String -> String -> V.Vector Word8 -> IO (V.Vector Word8)
+convertToBinary :: Int32 -> Int32 -> Text -> Text -> V.Vector Word8 -> IO (V.Vector Word8)
 convertToBinary width height fgHex bgHex input =
-  withCString fgHex $ \fgPtr ->
-    withCString bgHex $ \bgPtr ->
+  withCString (T.unpack fgHex) $ \fgPtr ->
+    withCString (T.unpack bgHex) $ \bgPtr ->
       withImageData input (\p -> Raw.fcv_convert_to_binary p width height fgPtr bgPtr)
         (fromIntegral $ width * height * 4)
 
diff --git a/src/FlatCV/Raw.chs b/src/FlatCV/Raw.chs
--- a/src/FlatCV/Raw.chs
+++ b/src/FlatCV/Raw.chs
@@ -513,3 +513,20 @@
   , `Bool'                        -- ^ create_boundaries
   } -> `Ptr Word8' toWord8Ptr
 #}
+
+--------------------------------------------------------------------------------
+-- Flood fill
+--------------------------------------------------------------------------------
+
+-- | Flood fill segmentation
+{#fun fcv_flood_fill
+  { `Word32'                      -- ^ width
+  , `Word32'                      -- ^ height
+  , fromWord8Ptr `Ptr Word8'      -- ^ grayscale data
+  , `Point2DPtr'                  -- ^ markers array
+  , `Word32'                      -- ^ num_markers
+  , `Word8'                       -- ^ threshold
+  , `Word8'                       -- ^ new_value
+  , `Word8'                       -- ^ connectivity
+  } -> `Ptr Word8' toWord8Ptr
+#}
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -2,6 +2,8 @@
 
 module Main where
 
+import Data.Text (Text)
+import qualified Data.Text as T
 import Data.Word (Word8)
 import qualified Data.Vector.Storable as V
 import Test.Hspec
@@ -184,7 +186,7 @@
     describe "prettyShowCorners" $ do
       it "formats corners as a readable string" $ do
         let corners = Corners 0 0 100 0 100 100 0 100
-        prettyShowCorners corners `shouldBe` "(0.0,0.0) (100.0,0.0)\n(0.0,100.0) (100.0,100.0)"
+        prettyShowCorners corners `shouldBe` ("(0.0,0.0) (100.0,0.0)\n(0.0,100.0) (100.0,100.0)" :: Text)
 
     describe "Matrix3x3" $ do
       it "has correct Storable instance" $ do
@@ -198,8 +200,8 @@
       it "formats matrix as a readable string" $ do
         let mat = Matrix3x3 1 0 0 0 1 0 0 0 1
         -- Just check it contains the values and has newlines
-        prettyShowMatrix3x3 mat `shouldContain` "1.00000"
-        prettyShowMatrix3x3 mat `shouldContain` "\n"
+        prettyShowMatrix3x3 mat `shouldSatisfy` T.isInfixOf "1.00000"
+        prettyShowMatrix3x3 mat `shouldSatisfy` T.isInfixOf "\n"
 
     describe "calculatePerspectiveTransform" $ do
       it "calculates identity-like transform for same corners" $ do
@@ -243,6 +245,24 @@
         outW `shouldBe` 8  -- 4 + 2*2
         outH `shouldBe` 8
         V.length result `shouldBe` 256  -- 8x8x4
+
+    describe "floodFill" $ do
+      it "fills the connected region within threshold of the seed" $ do
+        let input = V.fromList [10, 12, 200, 11]
+        result <- floodFill 4 1 [Point2D 0 0] 5 0 Connectivity4 input
+        result `shouldBe` V.fromList [0, 0, 200, 11]
+
+      it "fills one region per seed" $ do
+        let input = V.fromList [10, 12, 200, 11]
+        result <- floodFill 4 1 [Point2D 0 0, Point2D 3 0] 5 0 Connectivity4 input
+        result `shouldBe` V.fromList [0, 0, 200, 0]
+
+      it "only crosses diagonal gaps with 8-connectivity" $ do
+        let input = V.fromList [0, 255, 0, 255, 0, 255, 0, 255, 0]
+        result4 <- floodFill 3 3 [Point2D 1 1] 0 100 Connectivity4 input
+        result8 <- floodFill 3 3 [Point2D 1 1] 0 100 Connectivity8 input
+        result4 `shouldBe` V.fromList [0, 255, 0, 255, 100, 255, 0, 255, 0]
+        result8 `shouldBe` V.fromList [100, 255, 100, 255, 100, 255, 100, 255, 100]
 
     describe "convertToBinary" $ do
       it "converts to binary with custom colors" $ do
