R/morph_utils.R

Defines functions `%||%` .solve_color_match_pipeline .solve_color_walk_pipeline .palette_pairs_lap .palette_pairs_identity .fill_unassigned_identity .assemble_assignment .build_spatial_assignments_for_pairs .exact_cost_and_solve .expand_patch_assignment .patch_cost_matrix .lap_assign .upscale_assignment .downscale_both .cpp_extract_patches .cpp_overlap .cpp_render_morph .cpp_upscale_assignment .cpp_downscale .cpp_compute_pixel_cost .cpp_spatial_cost .cpp_palette_info .call_or .clamp_rgb .from_planar_rgb .from_planar_rgb .to_planar_rgb .to_array_rgb .gif_delay_from_fps .has_namespace

# R/utils_morph.R
# Helpers for pixel-level morphing (exact / color_walk / auto)

# -------------------------------------------------------------------
# Basic helpers
# -------------------------------------------------------------------

#' @noRd
.has_namespace <- function(pkg) requireNamespace(pkg, quietly = TRUE)

#' GIF delay (centiseconds) from FPS
#' @noRd
.gif_delay_from_fps <- function(fps) {
  fps <- suppressWarnings(as.integer(round(fps)))
  if (!is.finite(fps) || fps < 1L) fps <- 10L
  as.integer(round(100 / fps))  # ImageMagick expects centiseconds/frame
}

# -------------------------------------------------------------------
# Image <-> array conversions
# -------------------------------------------------------------------

#' Convert magick image to H x W x 3 integer array in 0-255
#' @noRd
.to_array_rgb <- function(img) {
  a <- magick::image_data(img, channels = "rgb")
  d <- dim(a)
  if (length(d) != 3) stop("magick::image_data did not return a 3D array")

  # Convert to integer 0..255
  if (is.character(a)) {
    a <- array(as.integer(strtoi(a, base = 16L)), dim = d)
  } else if (is.raw(a)) {
    storage.mode(a) <- "integer"
  } else if (is.numeric(a)) {
    mx <- suppressWarnings(max(a, na.rm = TRUE))
    if (is.finite(mx) && mx <= 1.00001) a <- a * 255
    a <- round(a); storage.mode(a) <- "integer"
  } else {
    storage.mode(a) <- "integer"
  }

  # magick returns [channel, width, height] = [3, W, H]
  if (d[1] == 3 && d[3] != 3) {
    W <- d[2]; H <- d[3]
    out <- array(0L, dim = c(H, W, 3L))
    for (ch in 1:3) out[,,ch] <- t(a[ch,,])
  } else if (d[3] == 3) {
    out <- a
  } else if (d[2] == 3) {
    W <- d[1]; H <- d[3]
    out <- array(0L, dim = c(H, W, 3L))
    for (ch in 1:3) out[,,ch] <- t(a[,ch,])
  } else {
    stop("Unexpected RGB array dims: ", paste(d, collapse = " x "))
  }
  storage.mode(out) <- "integer"
  out
}

#' Convert H x W x 3 array to planar format (RRR...GGG...BBB...)
#' @noRd
.to_planar_rgb <- function(rgb_arr) {
  H <- dim(rgb_arr)[1]; W <- dim(rgb_arr)[2]
  planar <- numeric(H * W * 3L)
  for (ch in 1:3) {
    offset <- (ch - 1L) * H * W
    planar[(offset + 1L):(offset + H * W)] <- as.vector(rgb_arr[,,ch])
  }
  planar
}

#' Convert planar RGB vector (RRR...GGG...BBB...) back to H x W x 3 array
#' @noRd
.from_planar_rgb <- function(planar, H, W) {
  H <- as.integer(H)
  W <- as.integer(W)
  N <- H * W

  if (length(planar) != 3L * N) {
    stop(
      sprintf(
        ".from_planar_rgb: expected length %d (3 * %d * %d), got %d",
        3L * N, H, W, length(planar)
      ),
      call. = FALSE
    )
  }

  arr <- array(0, dim = c(H, W, 3L))

  for (ch in 1:3) {
    offset <- (ch - 1L) * N
    block  <- planar[(offset + 1L):(offset + N)]
    # as.vector() in .to_planar_rgb used column-major;
    # so we reconstruct with byrow = FALSE
    arr[,, ch] <- matrix(block, nrow = H, ncol = W, byrow = FALSE)
  }

  arr
}


#' Convert planar format back to H x W x 3 array
#' @noRd
.from_planar_rgb <- function(planar, H, W) {
  HW <- as.integer(H) * as.integer(W)
  if (!is.numeric(planar) || length(planar) != HW * 3L) {
    stop("planar data has wrong length; expected ", HW * 3L)
  }
  arr <- array(0, dim = c(as.integer(H), as.integer(W), 3L))
  arr[,,1] <- matrix(planar[1:HW], nrow = H, ncol = W, byrow = FALSE)
  arr[,,2] <- matrix(planar[(HW + 1):(2 * HW)], nrow = H, ncol = W, byrow = FALSE)
  arr[,,3] <- matrix(planar[(2 * HW + 1):(3 * HW)], nrow = H, ncol = W, byrow = FALSE)
  arr
}

#' Clamp RGB values to 0-255
#' @noRd
.clamp_rgb <- function(x) {
  d <- dim(x); x <- suppressWarnings(as.integer(round(x)))
  x[x < 0L] <- 0L; x[x > 255L] <- 255L
  if (!is.null(d)) dim(x) <- d
  x
}

# -------------------------------------------------------------------
# C++ glue (robust wrappers: prefer new _symbols; fallback to *cpp names)
# -------------------------------------------------------------------

.call_or <- function(sym, fallback, ...) {
  if (exists(sym, mode = "function")) {
    get(sym)(...)
  } else if (exists(fallback, mode = "function")) {
    get(fallback)(...)
  } else {
    stop("Neither ", sym, " nor ", fallback, " is available")
  }
}

#' @noRd
.cpp_palette_info <- function(Ap, Bp, H, W, bits) {
  .call_or("_color_palette_info", "color_palette_info_cpp",
           Ap, Bp, as.integer(H), as.integer(W), as.integer(bits))
}

#' @noRd
.cpp_spatial_cost <- function(idxA, idxB, H, W) {
  .call_or("_spatial_cost_matrix", "spatial_cost_matrix_cpp",
           as.integer(idxA), as.integer(idxB), as.integer(H), as.integer(W))
}

#' @noRd
.cpp_compute_pixel_cost <- function(Ap, Bp, H, W, alpha, beta) {
  .call_or("_compute_pixel_cost", "compute_pixel_cost_cpp",
           Ap, Bp, as.integer(H), as.integer(W), as.numeric(alpha), as.numeric(beta))
}

#' @noRd
.cpp_downscale <- function(planar, H, W, Hn, Wn) {
  .call_or("_downscale_image", "downscale_image_cpp",
           planar, as.integer(H), as.integer(W), as.integer(Hn), as.integer(Wn))
}

#' @noRd
.cpp_upscale_assignment <- function(asg_scaled, H, W, Hs, Ws) {
  .call_or("_upscale_assignment", "upscale_assignment_cpp",
           as.integer(asg_scaled), as.integer(H), as.integer(W), as.integer(Hs), as.integer(Ws))
}

#' @noRd
.cpp_render_morph <- function(Ap, Bp, asg0, H, W, nF) {
  .call_or("_morph_pixel_level_impl", "morph_pixel_level_cpp",
           Ap, Bp, as.integer(asg0), as.integer(H), as.integer(W), as.integer(nF))
}

#' @noRd
.cpp_overlap <- function(Ap, Bp, H, W, bits) {
  .call_or("_analyze_color_overlap", "analyze_color_overlap_cpp",
           Ap, Bp, as.integer(H), as.integer(W), as.integer(bits))
}

#' @noRd
.cpp_extract_patches <- function(P, H, W, patch) {
  .call_or("_extract_patches", "extract_patches_cpp",
           P, as.integer(H), as.integer(W), as.integer(patch))
}

# -------------------------------------------------------------------
# Downscale helpers for orchestration
# -------------------------------------------------------------------

#' Compute downscaled images, return both originals and downscaled with dims
#' @noRd
.downscale_both <- function(A_planar, B_planar, H, W, steps) {
  if (is.null(steps) || steps <= 0L) {
    return(list(Hs = as.integer(H), Ws = as.integer(W),
                A_s = A_planar, B_s = B_planar,
                H  = as.integer(H), W  = as.integer(W)))
  }
  Hs <- as.integer(max(8L, floor(H / (2^steps))))
  Ws <- as.integer(max(8L, floor(W / (2^steps))))
  A_s <- .cpp_downscale(A_planar, H, W, Hs, Ws)
  B_s <- .cpp_downscale(B_planar, H, W, Hs, Ws)
  list(Hs = Hs, Ws = Ws, A_s = A_s, B_s = B_s, H = as.integer(H), W = as.integer(W))
}

#' Upscale a downscaled assignment back to original resolution
#' @noRd
.upscale_assignment <- function(assign_s, H, W, Hs, Ws) {
  .cpp_upscale_assignment(assign_s, H, W, Hs, Ws)
}

# -------------------------------------------------------------------
# LAP glue -- normalize outputs
# -------------------------------------------------------------------

#' Solve LAP and return a consistent **0-based** column index per row
#' @noRd
.lap_assign <- function(C, method = "jv", maximize = FALSE) {
  if (!exists("lap_solve", mode = "function") && !exists("lap_solve_batch", mode = "function"))
    stop("No lap_solve / lap_solve_batch available")

  res <- if (exists("lap_solve", mode = "function")) {
    lap_solve(C, method = method, maximize = maximize)
  } else {
    lap_solve_batch(list(C), method = method, maximize = maximize)[[1L]]
  }

  n <- nrow(C)
  # Direct integer vector?
  if (is.integer(res) && length(res) == n) return(res - 1L)     # assume 1-based -> 0-based
  if (is.numeric(res) && length(res) == n) return(as.integer(round(res)) - 1L)

  # tibble/data.frame with source/target
  if (is.data.frame(res) && all(c("source","target") %in% names(res))) {
    perm <- integer(n); perm[res$source] <- res$target
    perm[perm == 0L] <- seq_len(n)[perm == 0L]
    return(as.integer(perm - 1L))
  }

  # common list shapes
  if (is.list(res)) {
    for (nm in c("assignment","perm","match")) {
      if (!is.null(res[[nm]]) && length(res[[nm]]) == n) {
        v <- res[[nm]]
        v <- if (is.integer(v)) v else as.integer(round(v))
        return(v - 1L)
      }
    }
  }
  stop("LAP solver returned an unsupported structure")
}

# -------------------------------------------------------------------
# Patch helpers
# -------------------------------------------------------------------

#' Compute cost matrix between two sets of patches (color+spatial)
#' @noRd
.patch_cost_matrix <- function(patches_a, patches_b, alpha = 1, beta = 0.1, H = NULL, W = NULL) {
  Ca <- as.matrix(patches_a$colors) / 255
  Cb <- as.matrix(patches_b$colors) / 255
  Sa <- as.matrix(patches_a$centers)
  Sb <- as.matrix(patches_b$centers)
  Cc <- as.matrix(stats::dist(rbind(Ca, Cb)))[seq_len(nrow(Ca)), nrow(Ca) + seq_len(nrow(Cb))]
  if (!is.null(H) && !is.null(W)) {
    diag_norm <- sqrt(H^2 + W^2)
  } else {
    # approximate from patch centers
    diag_norm <- max(dist(rbind(Sa, Sb)))
    if (!is.finite(diag_norm) || diag_norm <= 0) diag_norm <- 1
  }
  Cs <- as.matrix(stats::dist(rbind(Sa, Sb)))[seq_len(nrow(Sa)), nrow(Sa) + seq_len(nrow(Sb))] / diag_norm
  alpha * Cc + beta * Cs
}

#' Expand patch assignment to pixel assignment (simple spatial truncation)
#' @noRd
.expand_patch_assignment <- function(patch_assign, patches_a, patches_b, N) {
  out <- rep(-1L, N)
  for (i in seq_along(patch_assign)) {
    j <- patch_assign[[i]]
    if (!is.finite(j) || j < 1L) next
    ia <- patches_a$indices[[i]]
    ib <- patches_b$indices[[j]]
    take <- min(length(ia), length(ib))
    if (take > 0L) out[ia[seq_len(take)]] <- ib[seq_len(take)]
  }
  out
}

# -------------------------------------------------------------------
# Exact / Patch solves (R orchestrates LAP; C++ only builds costs)
# -------------------------------------------------------------------

#' Exact pixel-level: returns **1-based** assignment (A->B)
#' @noRd
.exact_cost_and_solve <- function(A_planar, B_planar, H, W, alpha = 1, beta = 0,
                                  method = "jv", maximize = FALSE) {
  C <- .cpp_compute_pixel_cost(A_planar, B_planar, H, W, alpha, beta)
  .lap_assign(C, method = method, maximize = maximize) + 1L
}

# NOTE: .patch_cost_and_solve() has been removed and replaced with
# .square_tiling_solver() in pixel_morph.R which is much more efficient

# -------------------------------------------------------------------
# Palette pipelines
# -------------------------------------------------------------------

# Build spatial assignments for palette pairs (returns index pairs)
.build_spatial_assignments_for_pairs <- function(info, pairs, H, W, method = "jv", maximize = FALSE) {
  if (nrow(pairs) == 0L) return(list(i_idx = integer(), j_idx = integer()))
  groupsA <- info$groupsA; groupsB <- info$groupsB
  i_all <- integer(0); j_all <- integer(0)
  for (r in seq_len(nrow(pairs))) {
    ia <- pairs$ia[[r]]; ib <- pairs$ib[[r]]; k <- pairs$k[[r]]
    if (k <= 0L) next
    idxA <- as.integer(groupsA[[ia]]); idxB <- as.integer(groupsB[[ib]])
    if (!length(idxA) || !length(idxB)) next
    idxA <- idxA[seq_len(min(k, length(idxA)))]
    idxB <- idxB[seq_len(min(k, length(idxB)))]
    Csp  <- .cpp_spatial_cost(idxA, idxB, H, W)
    perm <- .lap_assign(Csp, method = method, maximize = maximize)
    i_all <- c(i_all, idxA); j_all <- c(j_all, idxB[perm + 1L])
  }
  list(i_idx = i_all, j_idx = j_all)
}

# Assemble assignment from (i,j) pairs (1-based target); unfilled remain -1
.assemble_assignment <- function(N, i_idx, j_idx) {
  assign <- rep.int(-1L, N)
  if (length(i_idx) && length(j_idx)) {
    take <- min(length(i_idx), length(j_idx))
    if (take > 0L) assign[i_idx[seq_len(take)]] <- as.integer(j_idx[seq_len(take)])
  }
  assign
}

# Identity fill helper (kept, though color_walk will not use it)
.fill_unassigned_identity <- function(assign) {
  N <- length(assign)
  z <- which(assign < 0L)
  if (length(z)) assign[z] <- z
  assign
}

# Exact-identity palette pairs (quantized equality)
.palette_pairs_identity <- function(info) {
  A <- info$colorsA_rgb; B <- info$colorsB_rgb
  cA <- as.integer(info$countsA); cB <- as.integer(info$countsB)
  keyA <- paste(A[,1], A[,2], A[,3], sep = "_")
  keyB <- paste(B[,1], B[,2], B[,3], sep = "_")
  mapB <- seq_along(keyB); names(mapB) <- keyB
  ia_vec <- integer(0); ib_vec <- integer(0); k_vec <- integer(0)
  for (ia in seq_along(keyA)) {
    key <- keyA[[ia]]
    if (key %in% names(mapB)) {
      ib <- mapB[[key]]
      ia_vec <- c(ia_vec, ia)
      ib_vec <- c(ib_vec, ib)
      k_vec  <- c(k_vec, min(cA[[ia]], cB[[ib]]))
    }
  }
  if (!length(ia_vec)) return(data.frame(ia = integer(), ib = integer(), k = integer()))
  data.frame(ia = ia_vec, ib = ib_vec, k = k_vec)
}

# Palette LAP on color distance
.palette_pairs_lap <- function(info, method = "jv", maximize = FALSE) {
  cA <- as.integer(info$countsA); cB <- as.integer(info$countsB)
  M  <- info$color_dist
  if (nrow(M) == 0L || ncol(M) == 0L) return(data.frame(ia = integer(), ib = integer(), k = integer()))
  perm <- .lap_assign(M, method = method, maximize = maximize) + 1L
  ia <- seq_len(nrow(M)); ib <- as.integer(perm); k <- pmin(cA[ia], cB[ib])
  data.frame(ia = ia, ib = ib, k = as.integer(k))
}

# Color walk: process A colors in fixed order, match to nearest free B pixels by pure color distance
.solve_color_walk_pipeline <- function(A_planar, B_planar, H, W, quantize_bits = 5,
                                       method = "jv", maximize = FALSE) {
  info <- .cpp_palette_info(A_planar, B_planar, H, W, quantize_bits)
  N <- H * W
  
  # Extract full-resolution RGB for all pixels (0-255 range)
  A_rgb <- matrix(A_planar, nrow = N, ncol = 3)  # N x 3
  B_rgb <- matrix(B_planar, nrow = N, ncol = 3)  # N x 3
  
  # Sort A color groups by frequency (descending) for deterministic processing
  groupsA <- info$groupsA
  countsA <- info$countsA
  color_order <- order(countsA, decreasing = TRUE)
  
  # Initialize assignment and free set
  assignment <- rep(NA_integer_, N)  # will store 1-based B indices
  freeB <- rep(TRUE, N)
  
  # Process each A color group in order
  for (ia in color_order) {
    idxA <- as.integer(groupsA[[ia]])  # 1-based indices
    if (!length(idxA)) next
    
    # Get currently free B pixels
    idxB_free <- which(freeB)  # 1-based indices
    if (!length(idxB_free)) break  # no more free B pixels
    
    nA <- length(idxA)
    nB <- length(idxB_free)
    
    # Warn if this color group is very large (memory could be an issue)
    # Use as.numeric to avoid integer overflow in multiplication
    matrix_size <- as.numeric(nA) * as.numeric(nB)
    
    if (matrix_size > 1e8) {  # ~100M entries = ~800MB
      warning(sprintf(
        "Large color group: %d A pixels x %d B pixels. Using spatial fallback to avoid memory issues.",
        nA, nB
      ), call. = FALSE)
      # Use spatial matching as fallback for huge groups
      Csp <- .cpp_spatial_cost(idxA, idxB_free, H, W)
      match <- .lap_assign(Csp, method = method, maximize = FALSE)
    } else {
      # Normal color-based matching
      C_color <- matrix(0, nrow = nA, ncol = nB)
      
      for (i in seq_len(nA)) {
        a_idx <- idxA[i]
        for (j in seq_len(nB)) {
          b_idx <- idxB_free[j]
          dr <- (A_rgb[a_idx, 1] - B_rgb[b_idx, 1]) / 255.0
          dg <- (A_rgb[a_idx, 2] - B_rgb[b_idx, 2]) / 255.0
          db <- (A_rgb[a_idx, 3] - B_rgb[b_idx, 3]) / 255.0
          C_color[i, j] <- sqrt(dr*dr + dg*dg + db*db)
        }
      }
      
      match <- .lap_assign(C_color, method = method, maximize = FALSE)
    }
    
    # Apply assignments
    take <- min(nA, nB)
    if (take > 0) {
      for (i in seq_len(take)) {
        a_idx <- idxA[i]
        b_idx <- idxB_free[match[i] + 1L]
        assignment[a_idx] <- b_idx
        freeB[b_idx] <- FALSE
      }
    }
  }
  
  # Handle any remaining unassigned A pixels (shouldn't happen with equal sizes, but be safe)
  remainA <- which(is.na(assignment))
  if (length(remainA) && any(freeB)) {
    idxB_free <- which(freeB)
    nA <- length(remainA)
    nB <- length(idxB_free)
    C_color <- matrix(0, nrow = nA, ncol = nB)
    
    for (i in seq_len(nA)) {
      a_idx <- remainA[i]
      for (j in seq_len(nB)) {
        b_idx <- idxB_free[j]
        dr <- (A_rgb[a_idx, 1] - B_rgb[b_idx, 1]) / 255.0
        dg <- (A_rgb[a_idx, 2] - B_rgb[b_idx, 2]) / 255.0
        db <- (A_rgb[a_idx, 3] - B_rgb[b_idx, 3]) / 255.0
        C_color[i, j] <- sqrt(dr*dr + dg*dg + db*db)
      }
    }
    
    match <- .lap_assign(C_color, method = method, maximize = FALSE)
    for (i in seq_along(remainA)) {
      assignment[remainA[i]] <- idxB_free[match[i] + 1L]
    }
  }
  
  # Final fallback: any still unassigned -> identity
  still_na <- which(is.na(assignment))
  if (length(still_na)) assignment[still_na] <- still_na
  
  as.integer(assignment)  # return 1-based indices
}

# Identity palette pipeline (kept for completeness; not used in auto)
.solve_color_match_pipeline <- function(A_planar, B_planar, H, W, quantize_bits = 5,
                                        method = "jv", maximize = FALSE,
                                        fill_identity_for_unmatched = TRUE) {
  info  <- .cpp_palette_info(A_planar, B_planar, H, W, quantize_bits)
  pairs <- .palette_pairs_identity(info)
  pj    <- .build_spatial_assignments_for_pairs(info, pairs, H, W, method = method, maximize = maximize)
  assign <- .assemble_assignment(N = H * W, pj$i_idx, pj$j_idx)
  if (fill_identity_for_unmatched) assign <- .fill_unassigned_identity(assign)
  assign
}

# -------------------------------------------------------------------
# Hierarchical patch matching functions REMOVED
# -------------------------------------------------------------------
# The following inefficient functions have been removed:
# - .extract_patches_at_size() - replaced by square tiling
# - .extract_hierarchical_patches() - replaced by .generate_square_tiles()
# - .match_patches_at_size() - replaced by .solve_tile_lap()
# - .expand_patch_assignment_spatial() - replaced by local LAP per tile
# - .solve_hierarchical_patch_pipeline() - replaced by .square_tiling_solver()
#
# These functions used global LAPs with O(n^3) complexity and have been
# replaced with efficient local LAP solving in pixel_morph.R
# -------------------------------------------------------------------

# small infix helper
`%||%` <- function(a, b) if (!is.null(a)) a else b

Try the couplr package in your browser

Any scripts or data that you put into this service are public.

couplr documentation built on Jan. 20, 2026, 5:07 p.m.