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
61 changes: 61 additions & 0 deletions .github/workflows/auto-format.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Auto Format

on:
pull_request:
branches: ["main"]
paths-ignore:
- "*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".github/PULL_REQUEST_TEMPLATE/**"
- "LICENSE"

permissions:
contents: write

jobs:
auto-format:
runs-on: ubuntu-latest
# Skip for fork PRs — GITHUB_TOKEN lacks write access to fork branches
if: github.event.pull_request.head.repo.full_name == github.repository

steps:
- name: Checkout PR Branch
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install black
run: pip install black

- name: Get last commit message
id: last_commit
run: echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT

- name: Run black formatter
run: black minerva tests

- name: Check for changes
id: changes
run: |
if git diff --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi

- name: Commit formatted code
if: steps.changes.outputs.changed == 'true'
env:
COMMIT_MSG: ${{ steps.last_commit.outputs.message }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "formatted: $COMMIT_MSG"
git push
39 changes: 19 additions & 20 deletions .github/workflows/continuous-testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,25 @@ on:
push:
branches: ["main"]
paths-ignore:
- '**/README.md'
- '**/CONTRIBUTING.md'
- '**/CODE_OF_CONDUCT.md'
- '**/SECURITY.md'
- '**/.github/ISSUE_TEMPLATE/*'
- '**/.github/PULL_REQUEST_TEMPLATE/*'
- '**/LICENSE'
- "*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".github/PULL_REQUEST_TEMPLATE/**"
- "LICENSE"
pull_request:
branches: ["main"]
paths-ignore:
- '**/README.md'
- '**/CONTRIBUTING.md'
- '**/CODE_OF_CONDUCT.md'
- '**/SECURITY.md'
- '**/.github/ISSUE_TEMPLATE/*'
- '**/.github/PULL_REQUEST_TEMPLATE/*'
- '**/LICENSE'
- "*.md"
- ".github/ISSUE_TEMPLATE/**"
- ".github/PULL_REQUEST_TEMPLATE/**"
- "LICENSE"
workflow_run:
workflows: ["Auto Format"]
types:
- completed
branches: ["main"]

permissions:
contents: read # No write permission needed
contents: read # No write permission needed

jobs:
continuous-testing:
Expand All @@ -37,7 +36,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: "pip" # Cache pip packages to improve workflow runtime
cache: "pip" # Cache pip packages to improve workflow runtime

- name: Install Dependencies
run: |
Expand Down Expand Up @@ -65,8 +64,8 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: html-coverage-report
path: htmlcov/ # Upload the generated HTML report directory
path: htmlcov/ # Upload the generated HTML report directory

# ------- Run interrogate for docstring coverage (ignored on errors) -------
- name: Creating interrogate folder
run: |
Expand All @@ -75,9 +74,9 @@ jobs:
- name: Docstring Coverage
run: interrogate minerva -vv --fail-under=80 --generate-badge interrogate/interrogate_badge.svg --badge-format svg -o interrogate/simple-report.md
continue-on-error: true

- name: Upload Docstring Coverage Report
uses: actions/upload-artifact@v4
with:
name: docstring-coverage-files
path: interrogate/ # Upload the generated docstring coverage report
path: interrogate/ # Upload the generated docstring coverage report
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Minerva is currently under development and not yet available as a PyPI package.

### Install With pip
```bash
pip install minerva-ml
pip install minerva
```

### Install Locally
Expand Down
6 changes: 3 additions & 3 deletions docs/installation.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Installation

Minerva is currently under development but is already usable and have a development release available at [PyPI](https://pypi.org/project/minerva-ml/).
Minerva is currently under development but is already usable and have a development release available at [PyPI](https://pypi.org/project/minerva/).
You can install minerva for development or production use.

For production use, we recommend using the latest stable release available at [PyPI](https://pypi.org/project/minerva-ml/).
For production use, we recommend using the latest stable release available at [PyPI](https://pypi.org/project/minerva/).
For development, you can use the latest development version available at [GitHub](https://github.com/discovery-unicamp/Minerva.git) using pip or by installing the VSCode DevContainer (recommended).

## Install via PyPI

To install Minerva, you can use pip:

```bash
pip install minerva-ml
pip install minerva
```

This will install the latest version of Minerva and all its dependencies.
Expand Down
50 changes: 32 additions & 18 deletions minerva/transforms/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ def __str__(self) -> str:


class Crop(_Transform):
"""Crop an image to a given output size, with optional padding and bounding-box support.

Expects inputs in CHW (C, H, W) or 2D (H, W) format.
"""

def __init__(
self,
output_size: Tuple[int, int],
Expand All @@ -434,41 +439,50 @@ def __init__(
Valid modes include: 'constant', 'edge', 'linear_ramp', 'maximum', 'mean', 'median',
'minimum', 'reflect', 'symmetric', 'wrap', 'empty'.
coords : Tuple[float, float], optional
Top-left coordinates for the crop box as (X, Y).
Top-left coordinates for the crop box as (row, col).
Values must go from 0 to 1 indicating the relative position on where the
new top-left corner can be set, taking in consideration the new size.
Defaults to (0, 0) which corresponds to the top-left corner of the image.
bbox : Optional[Tuple[int, int, int, int]], optional
If provided, crops the image to the bounding box defined by (y1, y2, x1, x2).
If this parameter is set, the `coords` parameter is ignored. Defaults to None.

Returns
-------
np.ndarray
Cropped image, padded as necessary. Works with both 2D (grayscale) and
3D (color) images. Padding is applied symmetrically around the image
before cropping to the desired output size.
"""
self.output_size = output_size
self.pad_mode = pad_mode
self.coords = coords
self.bbox = bbox

def __call__(self, image: np.ndarray) -> np.ndarray:
h, w = image.shape[:2]
"""Crop the image to the configured output size.

Parameters
----------
image : np.ndarray
Input array in CHW (C, H, W) or 2D (H, W) format.

Returns
-------
np.ndarray
Cropped array. Shape is (C, new_h, new_w) for 3D input or
(new_h, new_w) for 2D input. Padded symmetrically if the
output size exceeds the input size.
"""
# Always read spatial dims from the last two axes (consistent with CHW)
h, w = image.shape[-2:]
new_h, new_w = self.output_size

# Apply padding if output size is larger than input size
if new_h > h or new_w > w:
pad_h = max(new_h - h, 0)
pad_w = max(new_w - w, 0)

# Handle both 2D and 3D arrays (grayscale and color images)
# 3D (C, H, W): pad only H and W, leave C untouched
# 2D (H, W): pad both dims directly
if len(image.shape) == 3:
pad_width = (
(0, 0),
(pad_h // 2, pad_h - pad_h // 2),
(pad_w // 2, pad_w - pad_w // 2),
(0, 0),
)
else:
pad_width = (
Expand All @@ -478,17 +492,17 @@ def __call__(self, image: np.ndarray) -> np.ndarray:

image = np.pad(image, pad_width, mode=self.pad_mode)

# Update dimensions after padding
h, w = image.shape[:2]
# Update spatial dims from last two axes after padding (CHW fix)
h, w = image.shape[-2:]

if self.bbox is not None:
y1, y2, x1, x2 = self.bbox
return image[y1:y2, x1:x2]
return image[..., y1:y2, x1:x2]

X, Y = self.coords
x = int((h - new_h) * X)
y = int((w - new_w) * Y)
return image[x : x + new_h, y : y + new_w]
row, col = self.coords
r = int((h - new_h) * row)
c = int((w - new_w) * col)
return image[..., r : r + new_h, c : c + new_w]

def __str__(self) -> str:
return f"Crop(output_size={self.output_size}, pad_mode={self.pad_mode}, coords={self.coords})"
Expand Down
5 changes: 3 additions & 2 deletions tests/transforms/test_random_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@


def test_random_crop_shape():
x = np.random.randint(0, 256, size=(50, 50, 3), dtype=np.uint8)
# CHW format: (C, H, W)
x = np.random.randint(0, 256, size=(3, 50, 50), dtype=np.uint8)
transform = RandomCrop(crop_size=(30, 30), seed=42)
crop_transform = transform.select_transform()
y = crop_transform(x)

assert y.shape == (30, 30, 3)
assert y.shape == (3, 30, 30)


def test_random_grayscale_prob_1():
Expand Down
30 changes: 26 additions & 4 deletions tests/transforms/test_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,19 +357,41 @@ def test_color_jitter_output_shape_and_effect():


def test_crop_output_shape():
x = np.random.randint(0, 256, size=(50, 50, 3), dtype=np.uint8)
# CHW format: (C, H, W)
x = np.random.randint(0, 256, size=(3, 50, 50), dtype=np.uint8)
transform = Crop(output_size=(30, 30), coords=(0.5, 0.5))
y = transform(x)

assert y.shape == (30, 30, 3)
assert y.shape == (3, 30, 30)


def test_crop_with_padding():
x = np.random.randint(0, 256, size=(20, 20, 3), dtype=np.uint8)
# CHW format: (C, H, W) — output larger than input triggers padding
x = np.random.randint(0, 256, size=(3, 20, 20), dtype=np.uint8)
transform = Crop(output_size=(40, 40), coords=(0.0, 0.0))
y = transform(x)

assert y.shape == (40, 40, 3)
assert y.shape == (3, 40, 40)


def test_crop_2d_input():
# 2D (H, W) arrays should also work
x = np.random.randint(0, 256, size=(50, 50), dtype=np.uint8)
transform = Crop(output_size=(30, 30), coords=(0.0, 0.0))
y = transform(x)

assert y.shape == (30, 30)


def test_crop_bbox():
# bbox=(y1, y2, x1, x2) selects that region regardless of coords
x = np.zeros((3, 50, 50), dtype=np.uint8)
x[:, 10:20, 10:20] = 255
transform = Crop(output_size=(10, 10), bbox=(10, 20, 10, 20))
y = transform(x)

assert y.shape == (3, 10, 10)
assert np.all(y == 255)


def test_grayscale_output():
Expand Down
Loading