diff --git a/DESCRIPTION b/DESCRIPTION index bb7c53e4..3ce1e02d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -83,6 +83,7 @@ Collate: 'dataset-vggface2.R' 'extension.R' 'globals.R' + 'item-transform-geometry.R' 'models-alexnet.R' 'models-convnext.R' 'models-convnext_detection.R' diff --git a/NAMESPACE b/NAMESPACE index fa95b649..b3f38dfe 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,7 @@ S3method(draw_bounding_boxes,default) S3method(draw_bounding_boxes,image_with_bounding_box) +S3method(draw_bounding_boxes,image_with_rotated_box) S3method(draw_bounding_boxes,torch_tensor) S3method(draw_segmentation_masks,default) S3method(draw_segmentation_masks,image_with_segmentation_mask) @@ -12,6 +13,8 @@ S3method(prepare_sahi_split,"magick-image") S3method(prepare_sahi_split,dataset) S3method(prepare_sahi_split,numeric) S3method(prepare_sahi_split,torch_tensor) +S3method(target_transform_rotate_box,dataset) +S3method(target_transform_rotate_box,image_with_bounding_box) S3method(transform_adjust_brightness,default) S3method(transform_adjust_brightness,torch_tensor) S3method(transform_adjust_contrast,default) @@ -83,6 +86,7 @@ export(batched_nms) export(box_area) export(box_convert) export(box_iou) +export(box_xyxy_to_xyxyr) export(caltech101_dataset) export(caltech256_dataset) export(caltech_classes) @@ -226,6 +230,7 @@ export(rf100_underwater_collection) export(search_collection) export(target_transform_coco_masks) export(target_transform_resize) +export(target_transform_rotate_box) export(target_transform_sahi_crop) export(target_transform_trimap_masks) export(tensor_image_browse) diff --git a/NEWS.md b/NEWS.md index 43da4fe8..4d521b84 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,7 @@ ## New features * Added `target_transform_resize` to manage bounding_box resizing in parallel to `transform_resize` for images (#337). +* Added `target_transform_rotate_box()` for rotating both images and bounding boxes by an arbitrary angle (in degrees), returning boxes in `xyxyr` format. Includes a new `draw_bounding_boxes` S3 method for `image_with_rotated_box` that renders rotated boxes as polygons. (@DerrickUnleashed, #338) ## Bug fixes and improvements diff --git a/R/item-transform-geometry.R b/R/item-transform-geometry.R new file mode 100644 index 00000000..e69de29b diff --git a/R/ops-box_convert.R b/R/ops-box_convert.R index 5224def1..a1e941df 100644 --- a/R/ops-box_convert.R +++ b/R/ops-box_convert.R @@ -76,3 +76,73 @@ box_xyxy_to_xywh <- function(boxes) { return(boxes) } +#' box_xyxy_to_xyxyr +#' +#' Converts bounding boxes from \eqn{(x_{min}, y_{min}, x_{max}, y_{max})} format to +#' \eqn{(x_{min}, y_{min}, x_{max}, y_{max}, r)} format, where \eqn{r} is the rotation +#' angle in degrees (anti-clockwise). For axis-aligned boxes, \eqn{r = 0}. +#' +#' @param boxes (Tensor\[N, 4\]): boxes in \eqn{(x_{min}, y_{min}, x_{max}, y_{max})} format +#' which will be converted. +#' @param angle (numeric, optional): Rotation angle in degrees (anti-clockwise). +#' A single numeric value applied to all boxes, or a tensor of shape \code{(N,)} +#' with one angle per box. Default is \code{0}. +#' +#' @return boxes (Tensor\[N, 5\]): boxes in \eqn{(x_{min}, y_{min}, x_{max}, y_{max}, r)} format, +#' where \eqn{r} is the provided rotation angle in degrees. The bounding box +#' coordinates are computed by rotating the original axis-aligned box around +#' its center by \eqn{r} degrees anti-clockwise, then taking the axis-aligned +#' bounding box of the rotated corners. +#' +#' @export +box_xyxy_to_xyxyr <- function(boxes, angle = 0) { + + n <- boxes$size(1) + + if (n == 0) { + angle_t <- torch_zeros(0, 1, dtype = boxes$dtype) + return(torch_cat(list(boxes, angle_t), dim = -1)) + } + + c(x1, y1, x2, y2) %<-% boxes$unbind(-1) + cx <- ((x1 + x2) / 2)$reshape(c(-1, 1)) + cy <- ((y1 + y2) / 2)$reshape(c(-1, 1)) + hw <- ((x2 - x1) / 2)$reshape(c(-1, 1)) + hh <- ((y2 - y1) / 2)$reshape(c(-1, 1)) + + if (inherits(angle, "torch_tensor")) { + angle_deg <- angle$to(dtype = boxes$dtype)$reshape(c(-1, 1)) + } else { + angle_deg <- torch_tensor(angle, dtype = boxes$dtype)$reshape(c(-1, 1)) + } + + if (angle_deg$size(1) == 1 && n > 1) { + angle_deg <- angle_deg$expand(c(n, 1)) + } + + angle_rad <- angle_deg * pi / 180 + ct <- torch_cos(angle_rad) + st <- torch_sin(angle_rad) + + corners_x <- torch_cat(list( + cx - hw * ct + hh * st, + cx + hw * ct + hh * st, + cx + hw * ct - hh * st, + cx - hw * ct - hh * st + ), dim = -1) + + corners_y <- torch_cat(list( + cy - hw * st - hh * ct, + cy + hw * st - hh * ct, + cy + hw * st + hh * ct, + cy - hw * st + hh * ct + ), dim = -1) + + xmin <- torch_min(corners_x, dim = -1)[[1]]$reshape(c(-1, 1)) + xmax <- torch_max(corners_x, dim = -1)[[1]]$reshape(c(-1, 1)) + ymin <- torch_min(corners_y, dim = -1)[[1]]$reshape(c(-1, 1)) + ymax <- torch_max(corners_y, dim = -1)[[1]]$reshape(c(-1, 1)) + + torch_cat(list(xmin, ymin, xmax, ymax, angle_deg), dim = -1L) +} + diff --git a/R/target_transform_detection.R b/R/target_transform_detection.R index a69d4019..6f66725c 100644 --- a/R/target_transform_detection.R +++ b/R/target_transform_detection.R @@ -199,3 +199,124 @@ target_transform_sahi_crop <- function(y, sahi_split, min_area_ratio = 0.1) { results } +#' Convert bounding boxes to rotated format +#' +#' Converts bounding boxes of a detection item from +#' \eqn{(x_{min}, y_{min}, x_{max}, y_{max})} (xyxy) format to +#' \eqn{(x_{min}, y_{min}, x_{max}, y_{max}, r)} (xyxyr) format, where +#' \eqn{r} is the rotation angle in degrees (counter-clockwise). The image +#' is left unchanged. For axis-aligned boxes, \eqn{r = 0}. +#' +#' @param x An object of class \code{image_with_bounding_box} or a dataset that +#' returns \code{image_with_bounding_box} items via \code{.getitem()}. +#' @param angle (numeric): Rotation angle in degrees (counter-clockwise). +#' Default is \code{0}. +#' +#' @return An object of class \code{image_with_rotated_box} with the same +#' structure as the input, except that +#' \code{$y$boxes} is a tensor of shape \code{(N, 5)} in xyxyr format. +#' When applied to a dataset, returns the same dataset with its +#' \code{.getitem} method modified to return \code{image_with_rotated_box} +#' items. +#' +#' @examples +#' \dontrun{ +#' url <- "https://upload.wikimedia.org/wikipedia/commons/c/c6/Jumping_dog.JPG" +#' +#' img <- base_loader(url) |> +#' transform_to_tensor() |> +#' transform_resize(c(300, 500)) +#' +#' boxes <- torch_tensor(matrix(c(90, 25, 450, 290), ncol = 4), dtype = torch_float32()) +#' +#' before <- list(x = img, y = list(boxes = boxes, labels = {"dog"}, image_height = 300L, image_width = 500L)) +#' class(before) <- c("image_with_bounding_box", "list") +#' +#' after <- target_transform_rotate_box(before, angle = 30) +#' +#' before_plot <- draw_bounding_boxes(before, color = "blue", width = 4) +#' after_plot <- draw_bounding_boxes(after, color = "red", width = 4) +#' +#' grid <- vision_make_grid(torch_stack(list(before_plot, after_plot))$to(torch_float32()), scale = TRUE) +#' tensor_image_browse(grid) +#' } +#' +#' @family item_unitary_transforms +#' +#' @export +target_transform_rotate_box <- function(x, angle = 0) { + UseMethod("target_transform_rotate_box", x) +} + +#' @export +target_transform_rotate_box.image_with_bounding_box <- function(x, angle = 0) { + img_h <- x$y$image_height + img_w <- x$y$image_width + + orig_boxes <- x$y$boxes + c(x1, y1, x2, y2) %<-% orig_boxes$unbind(-1) + cx <- ((x1 + x2) / 2)$reshape(c(-1, 1)) + cy <- ((y1 + y2) / 2)$reshape(c(-1, 1)) + + boxes <- box_xyxy_to_xyxyr(orig_boxes, angle = angle) + + if (!is.null(img_h) && !is.null(img_w)) { + img_h <- as.numeric(img_h) + img_w <- as.numeric(img_w) + + angle_col <- boxes[, 5] + angle_rad <- angle_col$reshape(c(-1, 1)) * pi / 180 + ct <- torch_cos(angle_rad) + st <- torch_sin(angle_rad) + + hw <- ((x2 - x1) / 2)$reshape(c(-1, 1)) + hh <- ((y2 - y1) / 2)$reshape(c(-1, 1)) + + dx <- torch_cat(list( + -hw * ct + hh * st, + hw * ct + hh * st, + hw * ct - hh * st, + -hw * ct - hh * st + ), dim = -1) + + dy <- torch_cat(list( + -hw * st - hh * ct, + hw * st - hh * ct, + hw * st + hh * ct, + -hw * st + hh * ct + ), dim = -1) + + dist_left <- torch_max(torch_clamp(-dx, min = 0), dim = -1)[[1]]$reshape(c(-1, 1)) + dist_right <- torch_max(torch_clamp(dx, min = 0), dim = -1)[[1]]$reshape(c(-1, 1)) + dist_down <- torch_max(torch_clamp(-dy, min = 0), dim = -1)[[1]]$reshape(c(-1, 1)) + dist_up <- torch_max(torch_clamp(dy, min = 0), dim = -1)[[1]]$reshape(c(-1, 1)) + + eps <- 1e-8 + scale <- torch_min(torch_cat(list( + cx / torch_clamp(dist_left, min = eps), + (img_w - cx) / torch_clamp(dist_right, min = eps), + cy / torch_clamp(dist_down, min = eps), + (img_h - cy) / torch_clamp(dist_up, min = eps) + ), dim = -1), dim = -1)[[1]]$reshape(c(-1, 1)) + scale <- torch_clamp(scale, min = 0, max = 1.0) + + hw <- hw * scale + hh <- hh * scale + + boxes <- torch_cat(list(cx - hw, cy - hh, cx + hw, cy + hh, angle_col$reshape(c(-1, 1))), dim = -1L) + } + + x$y$boxes <- boxes + class(x) <- c("image_with_rotated_box", setdiff(class(x), "image_with_bounding_box")) + x +} + +#' @export +target_transform_rotate_box.dataset <- function(x, angle = 0) { + original_getitem <- x$.getitem + x$.getitem <- function(index) { + item <- original_getitem(index) + target_transform_rotate_box(item, angle = angle) + } + x +} \ No newline at end of file diff --git a/R/vision_utils.R b/R/vision_utils.R index 21b13cb7..474c6fb3 100644 --- a/R/vision_utils.R +++ b/R/vision_utils.R @@ -150,6 +150,9 @@ draw_bounding_boxes.torch_tensor <- function(x, cli_warn("boxes doesn't contain any box. No box was drawn") return(x) } + if (!is.null(labels) && inherits(labels, "torch_tensor")) { + labels <- as.character(as.array(labels$to(device = "cpu"))) + } if (!is.null(labels) && (num_boxes %% length(labels) != 0)) { cli_abort( "Number of labels {.val {length(labels)}} cannot be broadcasted on number of boxes {.val {num_boxes}}" @@ -219,7 +222,137 @@ draw_bounding_boxes.image_with_bounding_box <- function(x, ...) { ) } +#' @rdname draw_bounding_boxes +#' @export +draw_bounding_boxes.image_with_rotated_box <- function(x, + labels = NULL, + colors = NULL, + color = NULL, + fill = FALSE, + width = 1, + font = c("serif", "plain"), + font_size = 10, ...) { + rlang::check_installed("magick") + + if (!is.null(color)) { + if (!is.null(colors)) { + cli_abort("Use either {.arg colors} or {.arg color}, not both.") + } + cli_warn("{.arg color} is deprecated; use {.arg colors} instead.") + colors <- color + } + + boxes <- x$y$boxes + + img_to_draw <- if (x$x$dtype == torch::torch_uint8()) { + x$x$div(255)$permute(c(2, 3, 1))$to(device = "cpu") %>% as.array() + } else if (x$x$dtype == torch::torch_float()) { + x$x$permute(c(2, 3, 1))$to(device = "cpu") %>% as.array() + } else type_error("`x$x` should be torch_uint8 or torch_float") + + num_boxes <- boxes$shape[1] + if (num_boxes == 0) { + cli_warn("boxes doesn't contain any box. No box was drawn") + return(x$x) + } + + if (is.null(labels)) labels <- x$y$labels + if (!is.null(labels) && inherits(labels, "torch_tensor")) { + labels <- as.character(as.array(labels$to(device = "cpu"))) + } + if (!is.null(labels) && (num_boxes %% length(labels) != 0)) { + cli_abort( + "Number of labels {.val {length(labels)}} cannot be broadcasted on number of boxes {.val {num_boxes}}" + ) + } + + if (is.null(colors)) { + colors <- grDevices::hcl.colors(n = num_boxes) + } + if (num_boxes %% length(colors) != 0) { + value_error("colors vector cannot be broadcasted on boxes") + } + + if (!fill) { + fill_col <- NA + } else { + fill_col <- colors + } + if (is.null(font)) { + vfont <- c("serif", "plain") + } else { + if (is.null(font_size)) font_size <- 10 + } + + if (x$x$size(1) == 1) { + img_to_draw <- x$x$tile(c(4, 2, 2))$div(255)$permute(c(2, 3, 1))$to(device = "cpu") %>% as.array() + } + + boxes_r <- as.matrix(boxes$to(device = "cpu")) + + draw <- png::writePNG(img_to_draw) %>% + magick::image_read() %>% + magick::image_draw() + + img_h <- dim(img_to_draw)[1] + img_w <- dim(img_to_draw)[2] + graphics::clip(0, img_w, 0, img_h) + + for (i in seq_len(num_boxes)) { + xmin <- boxes_r[i, 1] + ymin <- boxes_r[i, 2] + xmax <- boxes_r[i, 3] + ymax <- boxes_r[i, 4] + theta <- boxes_r[i, 5] + + cx <- (xmin + xmax) / 2 + cy <- (ymin + ymax) / 2 + hw <- (xmax - xmin) / 2 + hh <- (ymax - ymin) / 2 + + theta_rad <- theta * pi / 180 + ct <- cos(theta_rad) + st <- sin(theta_rad) + + corners_x <- c( + cx - hw * ct + hh * st, + cx + hw * ct + hh * st, + cx + hw * ct - hh * st, + cx - hw * ct - hh * st + ) + corners_y <- c( + cy - hw * st - hh * ct, + cy + hw * st - hh * ct, + cy + hw * st + hh * ct, + cy - hw * st + hh * ct + ) + + graphics::polygon(corners_x, corners_y, + col = fill_col[(i - 1) %% length(fill_col) + 1], + border = colors[(i - 1) %% length(colors) + 1], + lwd = width) + + if (!is.null(labels)) { + lbl <- labels[(i - 1) %% length(labels) + 1] + graphics::text(corners_x[1] + 2 * width + font_size, + corners_y[1] + 2 * width, + labels = lbl, + col = colors[(i - 1) %% length(colors) + 1], + vfont = font, + cex = font_size / 10) + } + } + + grDevices::dev.off() + + draw_tt <- draw %>% + magick::image_data(channels = "rgb") %>% + as.integer() %>% + torch::torch_tensor(dtype = torch::torch_uint8()) + + draw_tt$permute(c(3, 1, 2)) +} #' Convert COCO polygon to mask tensor (Robust Version) #' diff --git a/_pkgdown.yml b/_pkgdown.yml index 21aef695..d359d280 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -33,30 +33,50 @@ navbar: href: articles/examples/keypoints.html reference: -- title: Transforms - desc: Image transformation functions -- subtitle: Unitary transformation +- title: Image Transforms + desc: Transformation functions applied to Image (tensor images, magick images,...) +- subtitle: Unitary image transformation contents: - has_concept("unitary_transforms") -- subtitle: Random transformation +- subtitle: Random image transformation contents: - has_concept("random_transforms") -- subtitle: Combining / multiplying transformations +- subtitle: Combining / multiplying image transformations contents: - has_concept("combining_transforms") + +- title: Target Transforms + desc: Transformation functions applied to targets (usualy the `$y` parameter) - subtitle: Target transformations for object detection desc: > - Functions to convert dataset native targets annotations into object-detection compatible - with draw_bounding_boxes() and object-detection models. + Functions to convert dataset native targets annotations into object-detection + targets compatible with draw_bounding_boxes() and object-detection models. contents: - has_concept("target_transforms_detection") - subtitle: Target transformations for segmentation desc: > - Functions to convert dataset native targets annotations into segmentation masks compatible - with draw_segmentation_masks() and segmentation models. + Functions to convert dataset native targets annotations into segmentation masks + targets compatible with draw_segmentation_masks() and segmentation models. contents: - has_concept("target_transforms_segmentation") +- title: Item Transforms + desc: > + Transformation functions modifying both image (`$x` parameter ) and target + (`$y` parameter ) of a single dataset item. Result shall be compatible with + `draw_bounding_boxes()` for object-detection items and with `draw_segmentation_mask()` + for semantic-segmentation items. +- subtitle: Unitary item transformations + desc: > + Functions to convert dataset **items** by an **unitary** transformation. + contents: + - has_concept("item_unitary_transforms") +- subtitle: Random item transformations + desc: > + Functions to convert dataset **items** by a **random** transformation. + contents: + - has_concept("item_random_transforms") + - title: Models desc: Computer Vision deep-learning Model architectures - subtitle: Classification models diff --git a/man/box_xyxy_to_xyxyr.Rd b/man/box_xyxy_to_xyxyr.Rd new file mode 100644 index 00000000..af9f91ef --- /dev/null +++ b/man/box_xyxy_to_xyxyr.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/ops-box_convert.R +\name{box_xyxy_to_xyxyr} +\alias{box_xyxy_to_xyxyr} +\title{box_xyxy_to_xyxyr} +\usage{ +box_xyxy_to_xyxyr(boxes, angle = 0) +} +\arguments{ +\item{boxes}{(Tensor[N, 4]): boxes in \eqn{(x_{min}, y_{min}, x_{max}, y_{max})} format +which will be converted.} + +\item{angle}{(numeric, optional): Rotation angle in degrees (anti-clockwise). +A single numeric value applied to all boxes, or a tensor of shape \code{(N,)} +with one angle per box. Default is \code{0}.} +} +\value{ +boxes (Tensor[N, 5]): boxes in \eqn{(x_{min}, y_{min}, x_{max}, y_{max}, r)} format, +where \eqn{r} is the provided rotation angle in degrees. The bounding box +coordinates are computed by rotating the original axis-aligned box around +its center by \eqn{r} degrees anti-clockwise, then taking the axis-aligned +bounding box of the rotated corners. +} +\description{ +Converts bounding boxes from \eqn{(x_{min}, y_{min}, x_{max}, y_{max})} format to +\eqn{(x_{min}, y_{min}, x_{max}, y_{max}, r)} format, where \eqn{r} is the rotation +angle in degrees (anti-clockwise). For axis-aligned boxes, \eqn{r = 0}. +} diff --git a/man/draw_bounding_boxes.Rd b/man/draw_bounding_boxes.Rd index 6bfbb1f9..39d80113 100644 --- a/man/draw_bounding_boxes.Rd +++ b/man/draw_bounding_boxes.Rd @@ -5,6 +5,7 @@ \alias{draw_bounding_boxes.default} \alias{draw_bounding_boxes.torch_tensor} \alias{draw_bounding_boxes.image_with_bounding_box} +\alias{draw_bounding_boxes.image_with_rotated_box} \title{Draws bounding boxes on image.} \usage{ draw_bounding_boxes(x, ...) @@ -25,6 +26,18 @@ draw_bounding_boxes(x, ...) ) \method{draw_bounding_boxes}{image_with_bounding_box}(x, ...) + +\method{draw_bounding_boxes}{image_with_rotated_box}( + x, + labels = NULL, + colors = NULL, + color = NULL, + fill = FALSE, + width = 1, + font = c("serif", "plain"), + font_size = 10, + ... +) } \arguments{ \item{x}{Tensor of shape (C x H x W) and dtype \code{uint8} or dtype \code{float}. diff --git a/man/target_transform_rotate_box.Rd b/man/target_transform_rotate_box.Rd new file mode 100644 index 00000000..71a577ce --- /dev/null +++ b/man/target_transform_rotate_box.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/target_transform_detection.R +\name{target_transform_rotate_box} +\alias{target_transform_rotate_box} +\title{Convert bounding boxes to rotated format} +\usage{ +target_transform_rotate_box(x, angle = 0) +} +\arguments{ +\item{x}{An object of class \code{image_with_bounding_box} or a dataset that +returns \code{image_with_bounding_box} items via \code{.getitem()}.} + +\item{angle}{(numeric): Rotation angle in degrees (counter-clockwise). +Default is \code{0}.} +} +\value{ +An object of class \code{image_with_rotated_box} with the same +structure as the input, except that +\code{$y$boxes} is a tensor of shape \code{(N, 5)} in xyxyr format. +When applied to a dataset, returns the same dataset with its +\code{.getitem} method modified to return \code{image_with_rotated_box} +items. +} +\description{ +Converts bounding boxes of a detection item from +\eqn{(x_{min}, y_{min}, x_{max}, y_{max})} (xyxy) format to +\eqn{(x_{min}, y_{min}, x_{max}, y_{max}, r)} (xyxyr) format, where +\eqn{r} is the rotation angle in degrees (counter-clockwise). The image +is left unchanged. For axis-aligned boxes, \eqn{r = 0}. +} +\examples{ +\dontrun{ +url <- "https://upload.wikimedia.org/wikipedia/commons/c/c6/Jumping_dog.JPG" + +img <- base_loader(url) |> + transform_to_tensor() |> + transform_resize(c(300, 500)) + +boxes <- torch_tensor(matrix(c(90, 25, 450, 290), ncol = 4), dtype = torch_float32()) + +before <- list(x = img, y = list(boxes = boxes, labels = {"dog"}, image_height = 300L, image_width = 500L)) +class(before) <- c("image_with_bounding_box", "list") + +after <- target_transform_rotate_box(before, angle = 30) + +before_plot <- draw_bounding_boxes(before, color = "blue", width = 4) +after_plot <- draw_bounding_boxes(after, color = "red", width = 4) + +grid <- vision_make_grid(torch_stack(list(before_plot, after_plot))$to(torch_float32()), scale = TRUE) +tensor_image_browse(grid) +} + +} +\concept{item_unitary_transforms} diff --git a/tests/testthat/test-item-transform-geometry.R b/tests/testthat/test-item-transform-geometry.R new file mode 100644 index 00000000..e69de29b diff --git a/tests/testthat/test-ops-box_convert.R b/tests/testthat/test-ops-box_convert.R index c7f82af5..d9bf22a6 100644 --- a/tests/testthat/test-ops-box_convert.R +++ b/tests/testthat/test-ops-box_convert.R @@ -1,8 +1,8 @@ -x1 <- torch::torch_ones(5) -y1 <- torch::torch_ones(5) +x1 <- torch_ones(5) +y1 <- torch_ones(5) x2 <- x1 + 1 y2 <- y1 + 1 -xyxy <- torch::torch_stack(list(x1,y1,x2,y2))$transpose(2,1) +xyxy <- torch_stack(list(x1,y1,x2,y2))$transpose(2,1) test_that("box_cxcywh_to_xyxy box_xyxy_to_cxcywh box_xywh_to_xyxy box_xyxy_to_xywh", { # from xyxy @@ -11,7 +11,74 @@ test_that("box_cxcywh_to_xyxy box_xyxy_to_cxcywh box_xywh_to_xyxy box_xyxy_to_xy expect_tensor(cxcywh) expect_tensor(xywh) - expect_equal(torch::as_array(cxcywh), torch::as_array(xyxy)) - expect_equal(torch::as_array(xywh), torch::as_array(xyxy)) + expect_equal(as_array(cxcywh), as_array(xyxy)) + expect_equal(as_array(xywh), as_array(xyxy)) }) +test_that("box_xyxy_to_xyxyr with angle=0 preserves original coordinates", { + result <- box_xyxy_to_xyxyr(xyxy, angle = 0) + + expect_tensor(result) + expect_tensor_shape(result, c(5, 5)) + expect_tensor_dtype(result, torch_float()) + expect_equal_to_r(result[, 1:4], as_array(xyxy)) + expect_equal_to_r(result[, 5], rep(0, 5)) +}) + +test_that("box_xyxy_to_xyxyr with angle=0 handles single box", { + single <- torch_tensor(matrix(c(10, 20, 50, 60), ncol = 4)) + result <- box_xyxy_to_xyxyr(single, angle = 0) + + expect_tensor_shape(result, c(1, 5)) + expect_tensor_dtype(result, torch_float()) + expect_equal_to_r(result[, 5], 0) +}) + +test_that("box_xyxy_to_xyxyr handles empty boxes", { + empty <- torch_zeros(c(0, 4)) + result <- box_xyxy_to_xyxyr(empty, angle = 0) + + expect_tensor_shape(result, c(0, 5)) + expect_tensor_dtype(result, torch_float()) +}) + +test_that("box_xyxy_to_xyxyr preserves dtype", { + boxes <- torch_tensor(matrix(c(1, 2, 3, 4), ncol = 4), dtype = torch::torch_float()) + result <- box_xyxy_to_xyxyr(boxes, angle = 0) + + expect_tensor_dtype(result, torch_float()) +}) + +test_that("box_xyxy_to_xyxyr rotates box by 90 degrees around center", { + # A 2x2 square centered at (0,0): xyxy = (-1, -1, 1, 1) + # Rotating by 90 degrees should give the same axis-aligned box + box <- torch_tensor(matrix(c(-1, -1, 1, 1), ncol = 4)) + result <- box_xyxy_to_xyxyr(box, angle = 90) + + expect_equal_to_r(result[, 1:4], as_array(box), tolerance = 1e-5) + expect_equal_to_r(result[1, 5], 90, tolerance = 1e-5) +}) + +test_that("box_xyxy_to_xyxyr rotates by 45 degrees and produces larger enclosing box", { + # A 2x2 square centered at origin: xyxy = (-1, -1, 1, 1) + # Rotated by 45 degrees, the enclosing box expands to [-sqrt(2), -sqrt(2), sqrt(2), sqrt(2)] + box <- torch_tensor(matrix(c(-1, -1, 1, 1), ncol = 4)) + result <- box_xyxy_to_xyxyr(box, angle = 45) + + expect_equal_to_r(result[1, 1], -sqrt(2), tolerance = 1e-5) + expect_equal_to_r(result[1, 2], -sqrt(2), tolerance = 1e-5) + expect_equal_to_r(result[1, 3], sqrt(2), tolerance = 1e-5) + expect_equal_to_r(result[1, 4], sqrt(2), tolerance = 1e-5) + expect_equal_to_r(result[1, 5], 45, tolerance = 1e-5) +}) + +test_that("box_xyxy_to_xyxyr accepts per-box angles", { + box <- torch_tensor(rbind(c(0, 0, 2, 2), c(0, 0, 2, 2))) + angles <- torch_tensor(c(0, 45)) + result <- box_xyxy_to_xyxyr(box, angle = angles) + + expect_tensor_shape(result, c(2, 5)) + expect_tensor_dtype(result, torch_float()) + expect_equal_to_r(result[1, 5], 0, tolerance = 1e-5) + expect_equal_to_r(result[2, 5], 45, tolerance = 1e-5) +}) diff --git a/tests/testthat/test-target_transform_detection.R b/tests/testthat/test-target_transform_detection.R index af771ef6..76d9a4c2 100644 --- a/tests/testthat/test-target_transform_detection.R +++ b/tests/testthat/test-target_transform_detection.R @@ -304,3 +304,118 @@ test_that("target_transform_resize does not mutate the input target", { # Result must be different expect_false(identical(as.matrix(out$boxes$cpu()), original_boxes)) }) + +make_item <- function(boxes, labels = NULL, image_size = c(100L, 200L)) { + if (is.matrix(boxes)) { + boxes <- torch_tensor(boxes, dtype = torch_float32()) + } + if (is.null(labels)) { + labels <- torch_ones(boxes$size(1), dtype = torch_long()) + } + x <- torch_randn(3, image_size[1], image_size[2]) + y <- list( + boxes = boxes, + labels = labels, + image_height = image_size[1], + image_width = image_size[2] + ) + item <- list(x = x, y = y) + class(item) <- c("image_with_bounding_box", "list") + item +} + +test_that("target_transform_rotate_box converts image_with_bounding_box to image_with_rotated_box", { + item <- make_item(matrix(c(10, 20, 50, 60), ncol = 4)) + result <- target_transform_rotate_box(item, angle = 0) + + expect_s3_class(result, "image_with_rotated_box") + expect_tensor_shape(result$y$boxes, c(1, 5)) + expect_tensor_dtype(result$y$boxes, torch_float()) + expect_equal_to_r(result$y$boxes[1, 1:4], c(10, 20, 50, 60)) + expect_equal_to_r(result$y$boxes[1, 5], 0) +}) + +test_that("target_transform_rotate_box does not modify the image", { + item <- make_item(matrix(c(10, 20, 50, 60), ncol = 4)) + original_x <- item$x$clone() + result <- target_transform_rotate_box(item, angle = 90) + + expect_true(result$x$eq(original_x)$all()$item()) + expect_tensor_shape(result$y$boxes, c(1, 5)) + expect_tensor_dtype(result$y$boxes, torch_float()) + expect_equal_to_r(result$y$boxes[1, 5], 90) +}) + +test_that("target_transform_rotate_box preserves labels and other target fields", { + item <- make_item( + boxes = matrix(c(10, 20, 50, 60, 5, 5, 15, 25), ncol = 4, byrow = TRUE), + labels = torch_tensor(c(1L, 2L), dtype = torch_long()) + ) + original_labels <- item$y$labels$clone() + + result <- target_transform_rotate_box(item, angle = 0) + + expect_true(result$y$labels$eq(original_labels)$all()$item()) + expect_equal(result$y$image_height, 100L) + expect_equal(result$y$image_width, 200L) +}) + +test_that("target_transform_rotate_box handles empty boxes", { + item <- make_item( + boxes = matrix(numeric(0), ncol = 4), + labels = torch_zeros(0L, dtype = torch_long()) + ) + result <- target_transform_rotate_box(item, angle = 0) + + expect_s3_class(result, "image_with_rotated_box") + expect_tensor_shape(result$y$boxes, c(0, 5)) + expect_tensor_dtype(result$y$boxes, torch_float()) +}) + +test_that("target_transform_rotate_box handles multiple boxes with angle=0", { + boxes <- matrix(c( + 10, 20, 50, 60, + 100, 200, 150, 250, + 0, 0, 300, 400 + ), ncol = 4, byrow = TRUE) + item <- make_item(boxes) + result <- target_transform_rotate_box(item, angle = 0) + + expect_tensor_shape(result$y$boxes, c(3, 5)) + expect_tensor_dtype(result$y$boxes, torch_float()) + expect_equal_to_r(result$y$boxes[, 5], c(0, 0, 0)) +}) + +test_that("target_transform_rotate_box does not mutate input", { + boxes <- torch_tensor(matrix(c(10, 20, 50, 60), ncol = 4)) + item <- make_item(boxes) + original_boxes <- boxes$clone() + original_x <- item$x$clone() + + result <- target_transform_rotate_box(item, angle = 0) + + expect_equal_to_r(item$y$boxes, as.array(original_boxes$cpu())) + expect_tensor_shape(item$y$boxes, c(1, 4)) + expect_false(inherits(item, "image_with_rotated_box")) + expect_true(item$x$eq(original_x)$all()$item()) +}) + +test_that("target_transform_rotate_box applies non-zero rotation angle to boxes", { + item <- make_item(boxes = matrix(c(100, 100, 102, 102), ncol = 4), image_size = c(200L, 200L)) + result <- target_transform_rotate_box(item, angle = 45) + + cx <- 101; cy <- 101 + hw <- 1; hh <- 1 + expect_tensor_shape(result$y$boxes, c(1, 5)) + expect_tensor_dtype(result$y$boxes, torch_float()) + expect_equal_to_r(result$y$boxes[1, 1], cx - hw, tolerance = 1e-5) + expect_equal_to_r(result$y$boxes[1, 3], cx + hw, tolerance = 1e-5) + expect_equal_to_r(result$y$boxes[1, 5], 45, tolerance = 1e-5) + + angle_rad <- 45 * pi / 180 + ct <- cos(angle_rad); st <- sin(angle_rad) + corners_x <- c(cx - hw*ct + hh*st, cx + hw*ct + hh*st, cx + hw*ct - hh*st, cx - hw*ct - hh*st) + corners_y <- c(cy - hw*st - hh*ct, cy + hw*st - hh*ct, cy + hw*st + hh*ct, cy - hw*st + hh*ct) + expect_true(all(corners_x >= 0 & corners_x <= 200)) + expect_true(all(corners_y >= 0 & corners_y <= 200)) +}) \ No newline at end of file