Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/changelog_check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Changelog Check

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/**'
- 'CHANGELOG.md'

jobs:
check-changelog:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Verify Changelog Update
run: |
# Get the base branch to compare against
BASE_BRANCH="origin/${{ github.base_ref }}"

echo "Checking for changes in src/ compared to $BASE_BRANCH..."

SRC_CHANGED=$(git diff --name-only $BASE_BRANCH...HEAD | grep "^src/" || true)
CHANGELOG_CHANGED=$(git diff --name-only $BASE_BRANCH...HEAD | grep "^CHANGELOG.md$" || true)

if [ -n "$SRC_CHANGED" ]; then
if [ -z "$CHANGELOG_CHANGED" ]; then
echo "❌ Changes detected in 'src/' but 'CHANGELOG.md' was not updated."
echo "Please add a summary of your changes to CHANGELOG.md."
exit 1
else
echo "✅ 'src/' changed and 'CHANGELOG.md' was updated."
fi
else
echo "✅ No changes in 'src/', skipping mandatory changelog update check."
fi
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,17 @@ jobs:

- name: Run Tests
run: cargo test --all-features

- name: Install cargo-tarpaulin
uses: taiki-e/install-action@v2
with:
tool: cargo-tarpaulin

- name: Generate Code Coverage
run: cargo tarpaulin --verbose --all-features --workspace --timeout 120 --out Xml --engine Llvm

- name: Upload to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - 2025-01-19

### Added

- **Contours**: Utilities for filtering by aspect ratio and sorting by perimeter or child count.
- **Rect**: Conversion from rotated rectangle vertices to axis-aligned bounding box (`Rect`).
- **Region Labelling**: Visualization tool to draw the N largest connected components with contrasting colors.
- **CI/CD**: Comprehensive pipeline with code coverage (Tarpaulin), clippy, and automated testing.
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ name = "image-debug-utils"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Niche but useful utilities for imageproc, including contour filtering, sorting, and visualization helpers."
repository = "https://github.com/bioinformatist/image-debug-utils"
readme = "README.md"
keywords = ["image", "imageproc", "debugging", "computer-vision", "visualization"]
categories = ["multimedia::images", "visualization", "development-tools::debugging"]
[lib]
path = "src/lib.rs"

Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2025 Richard Billyham
Copyright (c) 2026 Yu Sun

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bioinformatist/image-debug-utils)
[![Rust CI](https://github.com/bioinformatist/image-debug-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/bioinformatist/image-debug-utils/actions/workflows/ci.yml)
[![Build and Deploy to Pages](https://github.com/bioinformatist/image-debug-utils/actions/workflows/pages.yml/badge.svg)](https://github.com/bioinformatist/image-debug-utils/actions/workflows/pages.yml)
[![codecov](https://codecov.io/gh/bioinformatist/image-debug-utils/graph/badge.svg?token=23U4M79DJH)](https://codecov.io/gh/bioinformatist/image-debug-utils)

Some niche but useful utilities for [`imageproc`](https://github.com/image-rs/imageproc).

Expand Down
109 changes: 109 additions & 0 deletions src/contours.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
//! Specialized utilities for working with contours found by [`imageproc::contours`].
//!
//! This module provides functions for filtering, sorting, and analyzing the hierarchy of
//! contours, complementing the core functionality in `imageproc`.

use imageproc::{
contours::{BorderType, Contour},
geometry::min_area_rect,
Expand Down Expand Up @@ -31,6 +36,32 @@ use num_traits::AsPrimitive;
/// A `Vec<(Contour<T>, f64)>` sorted by the perimeter in descending order.
/// Contours with 0 or 1 point will have a perimeter of `0.0`.
///
/// # Examples
///
/// ```
/// use imageproc::contours::{Contour, BorderType};
/// use imageproc::point::Point;
/// use image_debug_utils::contours::sort_by_perimeters_owned;
///
/// let c1 = Contour {
/// parent: None,
/// border_type: BorderType::Outer,
/// points: vec![Point::new(0,0), Point::new(10,0), Point::new(10,10), Point::new(0,10)]
/// }; // Perimeter 40.0
///
/// let c2 = Contour {
/// parent: None,
/// border_type: BorderType::Outer,
/// points: vec![Point::new(0,0), Point::new(10,0)]
/// }; // Perimeter 20.0 (10 + 10 back to start)
///
/// let contours = vec![c2.clone(), c1.clone()];
///
/// let sorted = sort_by_perimeters_owned(contours);
/// assert_eq!(sorted[0].1, 40.0);
/// assert_eq!(sorted[1].1, 20.0);
/// ```
///
pub fn sort_by_perimeters_owned<T>(contours: Vec<Contour<T>>) -> Vec<(Contour<T>, f64)>
where
T: Num + NumCast + Copy + PartialEq + Eq + AsPrimitive<f64>,
Expand Down Expand Up @@ -74,6 +105,32 @@ where
/// * `max_aspect_ratio`: The maximum allowed aspect ratio. Must be a positive value.
/// * `border_type`: An `Option<BorderType>` to filter contours by their border type.
///
/// # Examples
///
/// ```
/// use imageproc::contours::{Contour, BorderType};
/// use imageproc::point::Point;
/// use image_debug_utils::contours::remove_hypotenuse_in_place;
///
/// let mut contours = vec![
/// // Square, aspect ratio 1.0 (keep)
/// Contour {
/// parent: None,
/// border_type: BorderType::Outer,
/// points: vec![Point::new(0,0), Point::new(10,0), Point::new(10,10), Point::new(0,10)]
/// },
/// // Thin strip, high aspect ratio > 5.0 (remove)
/// Contour {
/// parent: None,
/// border_type: BorderType::Outer,
/// points: vec![Point::new(0,0), Point::new(100,0), Point::new(100,2), Point::new(0,2)]
/// }
/// ];
///
/// remove_hypotenuse_in_place(&mut contours, 5.0, None);
/// assert_eq!(contours.len(), 1);
/// ```
///
/// # Panics
///
/// # Type Parameters
Expand Down Expand Up @@ -143,6 +200,23 @@ pub fn remove_hypotenuse_in_place<T>(
/// The time complexity is O(N log N), dominated by the final sort, where N is the number
/// of contours. The memory overhead is minimal as no deep copies of contour data occur.
///
/// # Examples
///
/// ```
/// use imageproc::contours::{Contour, BorderType};
/// use image_debug_utils::contours::sort_by_direct_children_count_owned;
///
/// // Create a hierarchy where index 0 is parent of index 1
/// let c0: Contour<i32> = Contour { parent: None, border_type: BorderType::Outer, points: vec![] };
/// let c1: Contour<i32> = Contour { parent: Some(0), border_type: BorderType::Hole, points: vec![] };
/// let contours = vec![c0, c1];
///
/// let sorted = sort_by_direct_children_count_owned(contours);
/// // c0 (sorted[0]) has 1 child, c1 (sorted[1]) has 0
/// assert_eq!(sorted[0].1, 1);
/// assert_eq!(sorted[1].1, 0);
/// ```
///
/// # Arguments
///
/// * `contours` - A `Vec<Contour<i32>>` which will be consumed by the function. The caller
Expand Down Expand Up @@ -298,6 +372,21 @@ mod tests {
contours5.is_empty(),
"Should filter out the high-ratio contour"
);

// --- Test Case 6: Degenerate contour (0-area) ---
let degenerate_contour_4pts = Contour {
points: vec![
Point::new(0, 0),
Point::new(0, 0),
Point::new(1, 0),
Point::new(1, 0),
],
border_type: BorderType::Outer,
parent: None,
};
let mut contours6 = vec![degenerate_contour_4pts];
remove_hypotenuse_in_place(&mut contours6, 5.0, None);
assert!(contours6.is_empty(), "0-area contour should be removed");
}

#[test]
Expand Down Expand Up @@ -338,6 +427,26 @@ mod tests {
assert!(result[3..].iter().all(|(_, count)| *count == 0));
}

#[test]
fn test_sort_by_direct_children_count_empty() {
let contours: Vec<Contour<i32>> = Vec::new();
let result = sort_by_direct_children_count_owned(contours);
assert!(result.is_empty());
}

#[test]
fn test_sort_by_direct_children_count_malformed_hierarchy() {
// Hierarchy with invalid parent index (out of bounds)
let contours = vec![Contour {
parent: Some(99), // Invalid index
border_type: BorderType::Outer,
points: vec![Point::new(1, 1)],
}];
let result = sort_by_direct_children_count_owned(contours);
assert_eq!(result.len(), 1);
assert_eq!(result[0].1, 0); // Invalid parent index should be ignored
}

#[test]
fn test_calculate_perimeters_and_sort_comprehensive() {
// 1. Test with an empty vector
Expand Down
40 changes: 38 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
//! A collection of debugging and visualization utilities for [imageproc].
//! `image-debug-utils` is a collection of niche but highly practical utilities designed to complement
//! the [`imageproc`] crate. It focuses on easing the debugging and visualization of common computer
//! vision tasks.
//!
//! The utility functions are organized into modules the same categories (as possible) as in [imageproc].
//! To ensure a familiar developer experience, the modules and functions are organized to mirror
//! the structure of [`imageproc`] as closely as possible.
//!
//! # Main Modules
//!
//! * [`contours`]: Utilities for working with contours found by `imageproc::contours`, including
//! filtering by aspect ratio and hierarchical sorting.
//! * [`rect`]: Tools for geometric primitives, such as converting rotated rectangle vertices
//! into axis-aligned bounding boxes (useful for `imageproc::geometry::min_area_rect`).
//! * [`region_labelling`]: Helpers for visualizing results from `imageproc::region_labelling`,
//! such as coloring principal connected components.
//!
//! # Example: Filtering and Sorting Contours
//!
//! ```rust
//! use imageproc::contours::Contour;
//! use imageproc::contours::BorderType;
//! use image_debug_utils::contours::{remove_hypotenuse_in_place, sort_by_perimeters_owned};
//! let mut contours: Vec<Contour<i32>> = Vec::new(); // Dummy data
//! // 1. Remove thin, "hypotenuse-like" artifacts from contours
//! remove_hypotenuse_in_place(&mut contours, 5.0, None);
//!
//! // 2. Sort remaining contours by their perimeter (descending)
//! let sorted = sort_by_perimeters_owned(contours);
//! ```
//!
//! # Example: Visualizing Connected Components
//!
//! ```rust
//! use image_debug_utils::region_labelling::draw_principal_connected_components;
//! use image::{Rgba, ImageBuffer, Luma};
//! let labelled_image = ImageBuffer::<Luma<u32>, Vec<u32>>::new(10, 10); // Dummy data
//! // Draw the top 5 largest connected components with contrasting colors
//! let colored_image = draw_principal_connected_components(&labelled_image, 5, Rgba([0, 0, 0, 255]));
//! ```

mod colors;
pub mod contours;
Expand Down
28 changes: 28 additions & 0 deletions src/rect.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Utilities for geometric primitives and bounding boxes, often used with `imageproc::geometry`.

use image::math::Rect;
use imageproc::point::Point;
use num_traits::{Num, ToPrimitive};
Expand Down Expand Up @@ -172,4 +174,30 @@ mod tests {
};
assert_eq!(to_axis_aligned_bounding_box(&vertices), expected);
}

#[test]
fn test_bounding_box_all_negative() {
// All points have negative coordinates.
// min_x = -100, max_x = -50, min_y = -80, max_y = -40
let vertices = [
Point { x: -50.0, y: -40.0 },
Point {
x: -100.0,
y: -40.0,
},
Point {
x: -100.0,
y: -80.0,
},
Point { x: -50.0, y: -80.0 },
];
// Everything should become 0.
let expected = Rect {
x: 0,
y: 0,
width: 0,
height: 0,
};
assert_eq!(to_axis_aligned_bounding_box(&vertices), expected);
}
}
30 changes: 30 additions & 0 deletions src/region_labelling.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Tools for visualizing and analyzing connected components results from `imageproc::region_labelling`.

use crate::colors::generate_contrasting_colors;
use image::{ImageBuffer, Luma, Rgba, RgbaImage};
use std::collections::HashMap;
Expand All @@ -9,6 +11,22 @@ use std::collections::HashMap;
/// * `n` - The number of largest components to keep and color.
/// * `background_color` - The color for the background and smaller, unselected components.
///
/// # Examples
///
/// ```
/// use image::{ImageBuffer, Luma, Rgba};
/// use image_debug_utils::region_labelling::draw_principal_connected_components;
///
/// let mut labelled_image = ImageBuffer::<Luma<u32>, Vec<u32>>::new(10, 10);
/// // Simulate a large component (label 1) and a small one (label 2)
/// labelled_image.put_pixel(0, 0, Luma([1]));
/// labelled_image.put_pixel(0, 1, Luma([1]));
/// labelled_image.put_pixel(5, 5, Luma([2]));
///
/// // Keep top 1 component, use transparent black for background
/// let colored = draw_principal_connected_components(&labelled_image, 1, Rgba([0, 0, 0, 0]));
/// ```
///
/// # Returns
/// An `RgbaImage` where the `n` largest components are colored and the rest is background.
pub fn draw_principal_connected_components(
Expand Down Expand Up @@ -116,4 +134,16 @@ mod tests {

assert_eq!(result_image, expected_image);
}

#[test]
fn test_draw_principal_connected_components_empty() {
// All background image.
let labelled_image = ImageBuffer::<Luma<u32>, Vec<u32>>::new(5, 5);
let background = Rgba([255, 255, 255, 255]);

// n = 0
let result = draw_principal_connected_components(&labelled_image, 0, background);
assert_eq!(result.dimensions(), (5, 5));
assert!(result.pixels().all(|p| *p == background));
}
}