|
| 1 | +"""Tests for voxcity.generator.voxelizer module.""" |
| 2 | +import pytest |
| 3 | +import numpy as np |
| 4 | + |
| 5 | +from voxcity.generator.voxelizer import ( |
| 6 | + Voxelizer, |
| 7 | + _flatten_building_segments, |
| 8 | + GROUND_CODE, |
| 9 | + TREE_CODE, |
| 10 | + BUILDING_CODE, |
| 11 | +) |
| 12 | + |
| 13 | + |
| 14 | +class TestVoxelCodes: |
| 15 | + def test_ground_code_value(self): |
| 16 | + assert GROUND_CODE == -1 |
| 17 | + |
| 18 | + def test_tree_code_value(self): |
| 19 | + assert TREE_CODE == -2 |
| 20 | + |
| 21 | + def test_building_code_value(self): |
| 22 | + assert BUILDING_CODE == -3 |
| 23 | + |
| 24 | + def test_codes_are_negative(self): |
| 25 | + """Voxel codes should be negative to distinguish from land cover.""" |
| 26 | + assert GROUND_CODE < 0 |
| 27 | + assert TREE_CODE < 0 |
| 28 | + assert BUILDING_CODE < 0 |
| 29 | + |
| 30 | + def test_codes_are_unique(self): |
| 31 | + codes = [GROUND_CODE, TREE_CODE, BUILDING_CODE] |
| 32 | + assert len(codes) == len(set(codes)) |
| 33 | + |
| 34 | + |
| 35 | +class TestFlattenBuildingSegments: |
| 36 | + def test_empty_grid(self): |
| 37 | + """Test with no buildings.""" |
| 38 | + # Must be 2D array with object dtype containing lists |
| 39 | + grid = np.empty((2, 2), dtype=object) |
| 40 | + for i in range(2): |
| 41 | + for j in range(2): |
| 42 | + grid[i, j] = [] |
| 43 | + starts, ends, offsets, counts = _flatten_building_segments(grid, 1.0) |
| 44 | + |
| 45 | + assert len(starts) == 0 |
| 46 | + assert len(ends) == 0 |
| 47 | + assert counts.sum() == 0 |
| 48 | + |
| 49 | + def test_single_building(self): |
| 50 | + """Test with one building segment.""" |
| 51 | + grid = np.array([ |
| 52 | + [[(0, 10)], []], |
| 53 | + [[], []] |
| 54 | + ], dtype=object) |
| 55 | + |
| 56 | + starts, ends, offsets, counts = _flatten_building_segments(grid, 1.0) |
| 57 | + |
| 58 | + assert counts[0, 0] == 1 |
| 59 | + assert counts[0, 1] == 0 |
| 60 | + assert starts[0] == 0 # min_height/voxel_size |
| 61 | + assert ends[0] == 10 # max_height/voxel_size |
| 62 | + |
| 63 | + def test_multiple_segments_same_cell(self): |
| 64 | + """Test with multiple building segments in one cell (stacked buildings).""" |
| 65 | + grid = np.array([ |
| 66 | + [[(0, 5), (10, 15)], []], |
| 67 | + [[], []] |
| 68 | + ], dtype=object) |
| 69 | + |
| 70 | + starts, ends, offsets, counts = _flatten_building_segments(grid, 1.0) |
| 71 | + |
| 72 | + assert counts[0, 0] == 2 |
| 73 | + assert starts[0] == 0 |
| 74 | + assert ends[0] == 5 |
| 75 | + assert starts[1] == 10 |
| 76 | + assert ends[1] == 15 |
| 77 | + |
| 78 | + def test_voxel_size_scaling(self): |
| 79 | + """Test that voxel size correctly scales segment heights.""" |
| 80 | + grid = np.empty((1, 1), dtype=object) |
| 81 | + grid[0, 0] = [(0, 10)] |
| 82 | + |
| 83 | + starts, ends, offsets, counts = _flatten_building_segments(grid, 2.0) |
| 84 | + |
| 85 | + # With voxel_size=2, heights should be halved |
| 86 | + assert starts[0] == 0 |
| 87 | + assert ends[0] == 5 # 10/2 |
| 88 | + |
| 89 | + |
| 90 | +class TestVoxelizer: |
| 91 | + @pytest.fixture |
| 92 | + def voxelizer(self): |
| 93 | + return Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch") |
| 94 | + |
| 95 | + def test_initialization(self, voxelizer): |
| 96 | + assert voxelizer.voxel_size == 1.0 |
| 97 | + assert voxelizer.land_cover_source == "Urbanwatch" |
| 98 | + |
| 99 | + def test_default_trunk_height_ratio(self, voxelizer): |
| 100 | + # Default ratio is 11.76/19.98 |
| 101 | + expected = 11.76 / 19.98 |
| 102 | + assert voxelizer.trunk_height_ratio == pytest.approx(expected) |
| 103 | + |
| 104 | + def test_custom_trunk_height_ratio(self): |
| 105 | + voxelizer = Voxelizer( |
| 106 | + voxel_size=1.0, |
| 107 | + land_cover_source="OpenStreetMap", |
| 108 | + trunk_height_ratio=0.5 |
| 109 | + ) |
| 110 | + assert voxelizer.trunk_height_ratio == 0.5 |
| 111 | + |
| 112 | + def test_voxel_dtype(self): |
| 113 | + voxelizer = Voxelizer( |
| 114 | + voxel_size=1.0, |
| 115 | + land_cover_source="Urbanwatch", |
| 116 | + voxel_dtype=np.int16 |
| 117 | + ) |
| 118 | + assert voxelizer.voxel_dtype == np.int16 |
| 119 | + |
| 120 | + def test_estimate_and_allocate(self, voxelizer): |
| 121 | + grid = voxelizer._estimate_and_allocate(10, 10, 20) |
| 122 | + assert grid.shape == (10, 10, 20) |
| 123 | + assert grid.dtype == np.int8 |
| 124 | + |
| 125 | + def test_convert_land_cover_osm(self): |
| 126 | + """OpenStreetMap should just add 1 to shift to 1-based indices.""" |
| 127 | + voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="OpenStreetMap") |
| 128 | + arr = np.array([[0, 1, 2]]) |
| 129 | + result = voxelizer._convert_land_cover(arr) |
| 130 | + assert result.tolist() == [[1, 2, 3]] |
| 131 | + |
| 132 | + def test_convert_land_cover_urbanwatch(self, voxelizer): |
| 133 | + """Urbanwatch should use the convert_land_cover function.""" |
| 134 | + arr = np.array([[0, 1, 2]], dtype=np.uint8) |
| 135 | + result = voxelizer._convert_land_cover(arr) |
| 136 | + # Should be mapped: 0->13, 1->12, 2->11 |
| 137 | + assert result.tolist() == [[13, 12, 11]] |
| 138 | + |
| 139 | + |
| 140 | +class TestVoxelizerGenerateCombined: |
| 141 | + @pytest.fixture |
| 142 | + def simple_inputs(self): |
| 143 | + """Create minimal input grids for testing.""" |
| 144 | + shape = (3, 3) |
| 145 | + |
| 146 | + # Building heights (10m building at center) |
| 147 | + building_heights = np.zeros(shape) |
| 148 | + building_heights[1, 1] = 10.0 |
| 149 | + |
| 150 | + # Building min heights (simple list structure) |
| 151 | + building_min_heights = np.empty(shape, dtype=object) |
| 152 | + for i in range(shape[0]): |
| 153 | + for j in range(shape[1]): |
| 154 | + building_min_heights[i, j] = [] |
| 155 | + building_min_heights[1, 1] = [(0, 10)] |
| 156 | + |
| 157 | + # Building IDs |
| 158 | + building_ids = np.zeros(shape, dtype=int) |
| 159 | + building_ids[1, 1] = 1 |
| 160 | + |
| 161 | + # Land cover (all grass = 2) |
| 162 | + land_cover = np.full(shape, 2, dtype=np.uint8) |
| 163 | + |
| 164 | + # DEM (flat terrain) |
| 165 | + dem = np.zeros(shape) |
| 166 | + |
| 167 | + # Tree heights (5m tree at corner) |
| 168 | + tree_heights = np.zeros(shape) |
| 169 | + tree_heights[0, 0] = 5.0 |
| 170 | + |
| 171 | + return { |
| 172 | + "building_height_grid_ori": building_heights, |
| 173 | + "building_min_height_grid_ori": building_min_heights, |
| 174 | + "building_id_grid_ori": building_ids, |
| 175 | + "land_cover_grid_ori": land_cover, |
| 176 | + "dem_grid_ori": dem, |
| 177 | + "tree_grid_ori": tree_heights, |
| 178 | + } |
| 179 | + |
| 180 | + def test_generate_combined_shape(self, simple_inputs): |
| 181 | + voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch") |
| 182 | + result = voxelizer.generate_combined(**simple_inputs, print_class_info=False) |
| 183 | + |
| 184 | + # Should have correct x,y dimensions |
| 185 | + assert result.shape[0] == 3 |
| 186 | + assert result.shape[1] == 3 |
| 187 | + # Z dimension should be > 0 |
| 188 | + assert result.shape[2] > 0 |
| 189 | + |
| 190 | + def test_generate_combined_has_building(self, simple_inputs): |
| 191 | + voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch") |
| 192 | + result = voxelizer.generate_combined(**simple_inputs, print_class_info=False) |
| 193 | + |
| 194 | + # Should have building code somewhere |
| 195 | + assert BUILDING_CODE in result |
| 196 | + |
| 197 | + def test_generate_combined_has_tree(self, simple_inputs): |
| 198 | + voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch") |
| 199 | + result = voxelizer.generate_combined(**simple_inputs, print_class_info=False) |
| 200 | + |
| 201 | + # Should have tree code somewhere |
| 202 | + assert TREE_CODE in result |
| 203 | + |
| 204 | + def test_generate_combined_has_ground(self, simple_inputs): |
| 205 | + voxelizer = Voxelizer(voxel_size=1.0, land_cover_source="Urbanwatch") |
| 206 | + result = voxelizer.generate_combined(**simple_inputs, print_class_info=False) |
| 207 | + |
| 208 | + # The voxelizer puts land cover class (positive int) at z=0 layer |
| 209 | + # Land cover values are positive (e.g., 11 for developed space from Urbanwatch) |
| 210 | + # Check that there are positive values at z=0 (land cover layer) |
| 211 | + assert np.any(result[:, :, 0] > 0) |
0 commit comments