diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml new file mode 100644 index 0000000..c7f7b51 --- /dev/null +++ b/.github/workflows/auto-format.yml @@ -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 diff --git a/.github/workflows/continuous-testing.yml b/.github/workflows/continuous-testing.yml index c576c1f..241864f 100644 --- a/.github/workflows/continuous-testing.yml +++ b/.github/workflows/continuous-testing.yml @@ -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: @@ -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: | @@ -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: | @@ -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 diff --git a/README.md b/README.md index 847047f..907f8a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/installation.md b/docs/installation.md index 593d838..95dd3da 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,9 +1,9 @@ # 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 @@ -11,7 +11,7 @@ For development, you can use the latest development version available at [GitHub 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. diff --git a/minerva/transforms/transform.py b/minerva/transforms/transform.py index 5fd35b9..f05b94a 100644 --- a/minerva/transforms/transform.py +++ b/minerva/transforms/transform.py @@ -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], @@ -434,20 +439,13 @@ 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 @@ -455,7 +453,22 @@ def __init__( 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 @@ -463,12 +476,13 @@ def __call__(self, image: np.ndarray) -> np.ndarray: 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 = ( @@ -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})" diff --git a/tests/transforms/test_random_transforms.py b/tests/transforms/test_random_transforms.py index d95a579..19ee5ac 100644 --- a/tests/transforms/test_random_transforms.py +++ b/tests/transforms/test_random_transforms.py @@ -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(): diff --git a/tests/transforms/test_transform.py b/tests/transforms/test_transform.py index d1cb5d2..62339dd 100644 --- a/tests/transforms/test_transform.py +++ b/tests/transforms/test_transform.py @@ -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():