Skip to content

Commit 95d716f

Browse files
committed
test(subplot): add visual test
1 parent 5b9cc5f commit 95d716f

File tree

3 files changed

+217
-84
lines changed

3 files changed

+217
-84
lines changed

.agent/SUMMARY/20260206_refactor_subplot_to_geotools.md

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 92 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,105 @@
1-
# Subplot Generator - Implementation Summary
2-
3-
## 20260206 Session: Subplot Generator Feature
4-
5-
### What Was Done
6-
7-
Implemented a subplot generator for EasyIDP that creates grid-based subplots within field boundary polygons.
8-
9-
### New Files
10-
11-
| File | Description |
12-
|------|-------------|
13-
| subplot.py | Core module with `generate_subplots()` function |
14-
| test_subplot.py | 19 test cases for subplot generation |
15-
16-
### Modified Files
17-
18-
| File | Changes |
19-
|------|---------|
20-
| roi.py | Added `save_shp()` method |
21-
| visualize.py | Added `show_subplots()` function |
22-
| \_\_init\_\_.py | Added subplot module imports |
23-
24-
### API Usage
1+
# Subplot Generator Feature Implementation
2+
3+
## Overview
4+
Implemented a subplot generator for EasyIDP that creates grid-based subplots within field boundary polygons. This feature supports both grid-count and fixed-size modes, handles non-rectangular boundaries with configurable filtering, and includes visualization tools.
5+
6+
During implementation, the code was refactored to integrate closely with existing modules (`geotools`, `shp`) rather than standing alone.
7+
8+
## Implementation Details
9+
10+
### 1. Subplot Generation (`src/easyidp/geotools.py`)
11+
- **Function**: `generate_subplots(boundary, ...)`
12+
- **Logic**:
13+
- Calculates Minimum Area Rectangle (MAR) to align grid with field orientation.
14+
- Generates grid cells based on `row_num`/`col_num` OR `width`/`height`.
15+
- Classifies subplots as `inside`, `touch`, or `outside` relative to the boundary.
16+
- Filters results based on `keep` parameter (`"all"`, `"touch"`, `"inside"`).
17+
- **Refactoring**:
18+
- Initially created in `subplot.py`, then merged into `geotools.py` to consolidate geometric utilities.
19+
- Deleted `subplot.py` and removed references from `__init__.py`.
20+
21+
### 2. Visualization (`src/easyidp/visualize.py`)
22+
- **Function**: `show_subplots(boundary_roi, subplot_roi, ...)`
23+
- **Features**:
24+
- Visualizes boundary and subplots on a matplotlib axis.
25+
- Color-codes subplots by status:
26+
- **Green**: Inside
27+
- **Orange**: Touch
28+
- **Red**: Outside
29+
- Supports saving to file via `save_as`.
30+
31+
### 3. File Saving (`src/easyidp/shp.py` & `src/easyidp/roi.py`)
32+
- **Refactoring**:
33+
- Extracted Shapefile writing logic from `ROI` class to a standalone function `write_shp` in `src/easyidp/shp.py`.
34+
- Updated `ROI.save_shp` to delegate to `idp.shp.write_shp`.
35+
- Added generic `ROI.save()` method.
36+
- **Features**: Does not just save geometry but also subplot metadata (`row`, `col`, `status`) to the Shapefile attributes (`.dbf`).
37+
38+
## API Usage
2539

2640
```python
2741
import easyidp as idp
2842

29-
# Load boundary
43+
# 1. Load boundary
3044
boundary = idp.ROI("field_boundary.shp")
3145

32-
# Generate by grid (row x col)
33-
subplots = idp.generate_subplots(
34-
boundary, row_num=4, col_num=6,
35-
x_interval=0.5, y_interval=0.5,
36-
keep="touch" # "all" | "touch" | "inside"
46+
# 2. Generate Subplots
47+
# Option A: By Grid (e.g., 4 rows x 6 cols)
48+
subplots = idp.geotools.generate_subplots(
49+
boundary,
50+
row_num=4,
51+
col_num=6,
52+
x_interval=0.5,
53+
y_interval=0.5,
54+
keep="touch" # Options: "all" | "touch" | "inside"
3755
)
3856

39-
# Or generate by size (width x height in meters)
40-
subplots = idp.generate_subplots(
41-
boundary, width=2.0, height=3.0,
42-
x_interval=0.3, y_interval=0.3
57+
# Option B: By Size (e.g., 2m x 3m plots)
58+
subplots_size = idp.geotools.generate_subplots(
59+
boundary,
60+
width=2.0,
61+
height=3.0
4362
)
4463

45-
# Visualize
46-
idp.visualize.show_subplots(boundary, subplots)
64+
# 3. Visualize
65+
idp.visualize.show_subplots(
66+
boundary,
67+
subplots,
68+
title="Field Subplots",
69+
save_as="subplots_vis.png"
70+
)
4771

48-
# Save to shapefile
49-
subplots.save_shp("output_subplots.shp")
72+
# 4. Save to Shapefile
73+
subplots.save("output_subplots.shp")
74+
# Or: subplots.save_shp("output_subplots.shp")
5075
```
5176

52-
### Key Features
53-
54-
- **Dual input modes**: Grid (row_num/col_num) or size (width/height)
55-
- **MAR-based orientation**: Automatically aligns to field direction
56-
- **Keep mode filtering**: `"all"`, `"touch"`, `"inside"` for non-rectangular boundaries
57-
- **Status tracking**: Each subplot has `inside`/`touch`/`outside` status
58-
- **Visualization**: Color-coded by status (green=inside, orange=touch, red=outside)
59-
60-
### Test Results
61-
62-
```
63-
tests/test_subplot.py: 19 passed ✓
64-
```
77+
## Testing
78+
79+
### Unit Tests
80+
- **Geotools Tests**: `tests/test_geotools.py` (Migrated from `test_subplot.py`)
81+
- Validates grid generation, naming conventions (`R1C1`), size calculations, and filtering logic.
82+
- 20 tests passed.
83+
- **Visualization Tests**: `tests/test_visualize.py` (`TestShowSubplots` class)
84+
- Generates sample images to verify rendering of different `keep` modes.
85+
- 4 tests passed.
86+
87+
### Visual Verification
88+
Generated test outputs in `tests/out/visual_test/`:
89+
- `rect_grid.png`: Standard grid on rectangular boundary.
90+
- `l_shape_keep_all.png`: L-shaped boundary showing all MAR subplots.
91+
- `l_shape_keep_touch.png`: Filtered to exclude fully distinct subplots.
92+
- `l_shape_keep_inside.png`: Filtered to include only fully contained subplots.
93+
94+
## Files Modified
95+
| File | Status | Description |
96+
|------|--------|-------------|
97+
| `src/easyidp/geotools.py` | Modified | Added `generate_subplots` and helpers |
98+
| `src/easyidp/shp.py` | Modified | Added `write_shp` function |
99+
| `src/easyidp/roi.py` | Modified | Refactored `save_shp` and added `save` |
100+
| `src/easyidp/visualize.py` | Modified | Added `show_subplots` |
101+
| `src/easyidp/__init__.py` | Modified | Removed old `subplot` references |
102+
| `tests/test_geotools.py` | Created | New home for subplot logic tests |
103+
| `tests/test_visualize.py` | Modified | Added visualization tests |
104+
| `src/easyidp/subplot.py` | Deleted | Merged into geotools |
105+
| `tests/test_subplot.py` | Deleted | Merged into test_geotools |

tests/test_visualize.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,128 @@ def test_draw_backward_one_roi(shared_data, report_loguru_to_caplog):
122122

123123
# Check that warning was logged via loguru
124124
assert "Expected title like ['title1', 'title2']" in report_loguru_to_caplog.text
125+
126+
127+
######################
128+
# Test show_subplots #
129+
######################
130+
131+
OUTPUT_DIR = Path("tests/out/visual_test")
132+
133+
class TestShowSubplots:
134+
"""Tests for subplot visualization output."""
135+
136+
@pytest.fixture
137+
def setup_out_dir(self):
138+
"""Ensure output directory exists."""
139+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
140+
return OUTPUT_DIR
141+
142+
@pytest.fixture
143+
def rectangular_boundary(self):
144+
"""Create a simple rectangular boundary ROI for testing."""
145+
roi = idp.ROI()
146+
# 10x20 meter rectangle
147+
roi["test_boundary"] = np.array([
148+
[0, 0],
149+
[20, 0],
150+
[20, 10],
151+
[0, 10],
152+
[0, 0],
153+
], dtype=float)
154+
roi.crs = pyproj.CRS.from_epsg(32654) # UTM 54N
155+
return roi
156+
157+
@pytest.fixture
158+
def l_shaped_boundary(self):
159+
"""Create an L-shaped boundary for testing non-rectangular cases."""
160+
roi = idp.ROI()
161+
# L-shape: 20x10 with 10x5 cut out from top-right
162+
roi["test_l_boundary"] = np.array([
163+
[0, 0],
164+
[20, 0],
165+
[20, 5],
166+
[10, 5],
167+
[10, 10],
168+
[0, 10],
169+
[0, 0],
170+
], dtype=float)
171+
roi.crs = pyproj.CRS.from_epsg(32654)
172+
return roi
173+
174+
def test_visualize_rect_grid(self, rectangular_boundary, setup_out_dir):
175+
"""Test visualization of rectangular boundary grid."""
176+
subplots = idp.geotools.generate_subplots(
177+
rectangular_boundary,
178+
row_num=4,
179+
col_num=6,
180+
x_interval=0.5,
181+
y_interval=0.5
182+
)
183+
184+
save_path = setup_out_dir / "rect_grid.png"
185+
idp.visualize.show_subplots(
186+
rectangular_boundary,
187+
subplots,
188+
title="Rectangular Boundary (4x6 grid)",
189+
save_as=str(save_path),
190+
show=False
191+
)
192+
assert save_path.exists()
193+
194+
def test_visualize_l_shape_keep_all(self, l_shaped_boundary, setup_out_dir):
195+
"""Test visualization of L-shaped boundary with keep='all'."""
196+
subplots = idp.geotools.generate_subplots(
197+
l_shaped_boundary,
198+
row_num=4,
199+
col_num=6,
200+
keep="all"
201+
)
202+
203+
save_path = setup_out_dir / "l_shape_keep_all.png"
204+
idp.visualize.show_subplots(
205+
l_shaped_boundary,
206+
subplots,
207+
title="L-Shape Boundary (keep='all')",
208+
save_as=str(save_path),
209+
show=False
210+
)
211+
assert save_path.exists()
212+
213+
def test_visualize_l_shape_keep_touch(self, l_shaped_boundary, setup_out_dir):
214+
"""Test visualization of L-shaped boundary with keep='touch'."""
215+
subplots = idp.geotools.generate_subplots(
216+
l_shaped_boundary,
217+
row_num=4,
218+
col_num=6,
219+
keep="touch"
220+
)
221+
222+
save_path = setup_out_dir / "l_shape_keep_touch.png"
223+
idp.visualize.show_subplots(
224+
l_shaped_boundary,
225+
subplots,
226+
title="L-Shape Boundary (keep='touch')",
227+
save_as=str(save_path),
228+
show=False
229+
)
230+
assert save_path.exists()
231+
232+
def test_visualize_l_shape_keep_inside(self, l_shaped_boundary, setup_out_dir):
233+
"""Test visualization of L-shaped boundary with keep='inside'."""
234+
subplots = idp.geotools.generate_subplots(
235+
l_shaped_boundary,
236+
row_num=4,
237+
col_num=6,
238+
keep="inside"
239+
)
240+
241+
save_path = setup_out_dir / "l_shape_keep_inside.png"
242+
idp.visualize.show_subplots(
243+
l_shaped_boundary,
244+
subplots,
245+
title="L-Shape Boundary (keep='inside')",
246+
save_as=str(save_path),
247+
show=False
248+
)
249+
assert save_path.exists()

0 commit comments

Comments
 (0)