Spaces:
Sleeping
Sleeping
| import unittest | |
| import io | |
| import tempfile | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image | |
| from fastapi.testclient import TestClient | |
| import app | |
| import direction_finder | |
| import floor_direction | |
| class MetricFloorGeometryTests(unittest.TestCase): | |
| def test_material_prepare_route_precedes_static_materials_mount(self): | |
| prepare_index = None | |
| static_index = None | |
| for index, route in enumerate(app.app.routes): | |
| if getattr(route, "path", None) == "/materials/prepare": | |
| prepare_index = index | |
| if getattr(route, "path", None) == "/materials": | |
| static_index = index | |
| self.assertIsNotNone(prepare_index) | |
| self.assertIsNotNone(static_index) | |
| self.assertLess(prepare_index, static_index) | |
| def test_tile_material_generation_returns_pbr_maps(self): | |
| height, width = 32, 48 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| tile = np.zeros((height, width, 3), dtype=np.uint8) | |
| tile[:, :, 0] = (xx * 255 / max(width - 1, 1)).astype(np.uint8) | |
| tile[:, :, 1] = (yy * 255 / max(height - 1, 1)).astype(np.uint8) | |
| tile[:, :, 2] = 128 | |
| maps, metadata = app.generate_tile_material_maps(tile, base_roughness=0.5) | |
| self.assertEqual(metadata["generator"], app.MATERIAL_GENERATOR_VERSION) | |
| self.assertEqual(metadata["generatedKinds"], ["normalMap", "heightMap", "roughnessMap", "aoMap", "specularMap"]) | |
| for key in ("albedoMap", "normalMap", "heightMap", "roughnessMap", "aoMap", "specularMap"): | |
| self.assertIn(key, maps) | |
| self.assertEqual(maps[key].shape, (height, width, 3)) | |
| self.assertEqual(maps[key].dtype, np.uint8) | |
| self.assertGreaterEqual(int(maps[key].min()), 0) | |
| self.assertLessEqual(int(maps[key].max()), 255) | |
| def test_tile_material_package_is_cached(self): | |
| tile = np.full((16, 16, 3), 160, dtype=np.uint8) | |
| tile[:, :8, :] = 80 | |
| buffer = io.BytesIO() | |
| Image.fromarray(tile).save(buffer, format="PNG") | |
| contents = buffer.getvalue() | |
| original_material_dir = app.MATERIAL_DIR | |
| with tempfile.TemporaryDirectory() as tmp_dir: | |
| app.MATERIAL_DIR = Path(tmp_dir) | |
| try: | |
| first = app.build_tile_material_package(contents, base_roughness=0.45) | |
| second = app.build_tile_material_package(contents, base_roughness=0.45) | |
| finally: | |
| app.MATERIAL_DIR = original_material_dir | |
| self.assertEqual(first["id"], second["id"]) | |
| self.assertTrue(second["cached"]) | |
| self.assertIn("normalMap", second["maps"]) | |
| self.assertTrue(second["maps"]["normalMap"].endswith("/normal.png")) | |
| def test_showroom_shade_map_clamps_harsh_floor_shadow(self): | |
| height, width = 96, 128 | |
| image = np.full((height, width, 3), 180, dtype=np.uint8) | |
| image[34:70, 46:82, :] = 45 | |
| mask = np.ones((height, width), dtype=np.uint8) | |
| maps = app.build_luminance_lighting_maps(image, mask) | |
| self.assertIsNotNone(maps) | |
| showroom = maps["showroomShadeMap"] | |
| self.assertIsNotNone(showroom) | |
| decoded = ( | |
| app.SHOWROOM_SHADE_MAP_MIN | |
| + (showroom.astype(np.float32) / 255.0) | |
| * (app.SHOWROOM_SHADE_MAP_MAX - app.SHOWROOM_SHADE_MAP_MIN) | |
| ) | |
| self.assertGreaterEqual(float(decoded.min()), app.SHOWROOM_SHADE_MAP_MIN) | |
| self.assertLessEqual(float(decoded.max()), app.SHOWROOM_SHADE_MAP_MAX) | |
| self.assertGreater(float(decoded[52, 64]), 0.90) | |
| def test_realistic_lighting_retains_dark_corner_and_bright_region(self): | |
| height, width = 180, 240 | |
| image = np.full((height, width, 3), 150, dtype=np.uint8) | |
| image[:, :80, :] = 55 | |
| image[:, 170:, :] = 225 | |
| mask = np.ones((height, width), dtype=np.uint8) | |
| maps = app.build_luminance_lighting_maps(image, mask) | |
| self.assertIsNotNone(maps) | |
| shade = maps["shadeMap"] | |
| self.assertIsNotNone(shade) | |
| decoded = ( | |
| app.SHADE_MAP_MIN | |
| + (shade.astype(np.float32) / 255.0) | |
| * (app.SHADE_MAP_MAX - app.SHADE_MAP_MIN) | |
| ) | |
| dark = float(np.median(decoded[:, 20:60])) | |
| neutral = float(np.median(decoded[:, 105:135])) | |
| bright = float(np.median(decoded[:, 190:225])) | |
| self.assertLess(dark, neutral * 0.25) | |
| self.assertGreater(bright, neutral * 1.25) | |
| def test_realistic_lighting_suppresses_old_floor_texture(self): | |
| height, width = 180, 240 | |
| image = np.full((height, width, 3), 165, dtype=np.uint8) | |
| image[:, :90, :] = 75 | |
| for x in range(0, width, 8): | |
| image[:, x:x + 3, :] = np.clip(image[:, x:x + 3, :].astype(np.int16) - 35, 0, 255) | |
| mask = np.ones((height, width), dtype=np.uint8) | |
| maps = app.build_luminance_lighting_maps(image, mask) | |
| self.assertIsNotNone(maps) | |
| shade = maps["shadeMap"] | |
| decoded = ( | |
| app.SHADE_MAP_MIN | |
| + (shade.astype(np.float32) / 255.0) | |
| * (app.SHADE_MAP_MAX - app.SHADE_MAP_MIN) | |
| ) | |
| shadow_contrast = float(np.median(decoded[:, 120:180]) - np.median(decoded[:, 20:70])) | |
| stripe_contrast = float(np.mean(np.abs(np.diff(decoded[:, 120:180], axis=1)))) | |
| self.assertGreater(shadow_contrast, 0.25) | |
| self.assertLess(stripe_contrast, 0.035) | |
| def test_intrinsic_fusion_cannot_weaken_observed_shadow_or_highlight(self): | |
| mask = np.ones((8, 12), dtype=np.uint8) | |
| intrinsic = np.ones((8, 12), dtype=np.float32) | |
| intrinsic[:, :4] = 0.72 | |
| intrinsic[:, 8:] = 1.18 | |
| observed = np.ones((8, 12), dtype=np.float32) | |
| observed[:, :4] = 0.18 | |
| observed[:, 8:] = 1.55 | |
| fused = app.preserve_observed_lighting(intrinsic, observed, mask) | |
| self.assertTrue(np.all(fused[:, :4] <= observed[:, :4])) | |
| self.assertTrue(np.all(fused[:, 8:] >= observed[:, 8:])) | |
| self.assertTrue(np.allclose(fused[:, 4:8], 1.0)) | |
| def test_reflection_map_only_transfers_bright_highlights(self): | |
| height, width = 128, 160 | |
| image = np.full((height, width, 3), 128, dtype=np.uint8) | |
| image[42:86, 28:68, :] = 52 | |
| image[42:86, 92:132, :] = 218 | |
| mask = np.ones((height, width), dtype=np.uint8) | |
| reflection = app.build_reflection_map(image, mask) | |
| self.assertIsNotNone(reflection) | |
| decoded = app.REFLECTION_MAP_MIN + (reflection.astype(np.float32) / 255.0) * ( | |
| app.REFLECTION_MAP_MAX - app.REFLECTION_MAP_MIN | |
| ) | |
| self.assertLessEqual(float(decoded[64, 48]), 0.02) | |
| self.assertGreater(float(decoded[42:86, 92:132].max()), 0.0) | |
| def test_intrinsic_shading_inversion_is_corrected_against_room_luminance(self): | |
| height, width = 160, 180 | |
| image = np.zeros((height, width, 3), dtype=np.uint8) | |
| image[: height // 2, :, :] = 210 | |
| image[height // 2 :, :, :] = 80 | |
| mask = np.ones((height, width), dtype=np.uint8) | |
| inverted_intrinsic = np.ones((height, width), dtype=np.float32) | |
| inverted_intrinsic[: height // 2, :] = 0.55 | |
| inverted_intrinsic[height // 2 :, :] = 1.45 | |
| aligned, source = app.align_intrinsic_shading_to_luminance( | |
| inverted_intrinsic, | |
| image, | |
| mask, | |
| ) | |
| self.assertEqual(source, "intrinsic-inverted-corrected") | |
| self.assertGreater(float(aligned[32, width // 2]), float(aligned[128, width // 2])) | |
| def test_intrinsic_shading_keeps_matching_room_luminance(self): | |
| height, width = 160, 180 | |
| image = np.zeros((height, width, 3), dtype=np.uint8) | |
| image[: height // 2, :, :] = 210 | |
| image[height // 2 :, :, :] = 80 | |
| mask = np.ones((height, width), dtype=np.uint8) | |
| matching_intrinsic = np.ones((height, width), dtype=np.float32) | |
| matching_intrinsic[: height // 2, :] = 1.45 | |
| matching_intrinsic[height // 2 :, :] = 0.55 | |
| aligned, source = app.align_intrinsic_shading_to_luminance( | |
| matching_intrinsic, | |
| image, | |
| mask, | |
| ) | |
| self.assertEqual(source, "intrinsic-aligned") | |
| self.assertGreater(float(aligned[32, width // 2]), float(aligned[128, width // 2])) | |
| def test_surface_uv_quality_keeps_smooth_complete_uvs(self): | |
| height, width = 60, 80 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| surface = np.ones((height, width), dtype=np.uint8) | |
| surface_indices = np.flatnonzero(surface.ravel()).astype(np.uint32) | |
| uv = np.column_stack(( | |
| xx.ravel()[surface_indices] * 0.02, | |
| yy.ravel()[surface_indices] * 0.02, | |
| )).astype(np.float32) | |
| plane_ids = np.zeros(len(surface_indices), dtype=np.uint8) | |
| quality = app.analyze_surface_uv_quality(surface, surface_indices, uv, plane_ids) | |
| self.assertTrue(quality["surfaceUvEnabled"]) | |
| self.assertEqual(quality["textureMappingMode"], "surface-uv") | |
| self.assertEqual(quality["textureMappingReason"], "surface-uv-quality-ok") | |
| def test_surface_uv_quality_rejects_noisy_far_region_jumps(self): | |
| height, width = 60, 80 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| surface = np.ones((height, width), dtype=np.uint8) | |
| surface_indices = np.flatnonzero(surface.ravel()).astype(np.uint32) | |
| uv_grid = np.dstack((xx * 0.02, yy * 0.02)).astype(np.float32) | |
| uv_grid[:20, 40::2, 0] += 4.0 | |
| uv = uv_grid.reshape(-1, 2)[surface_indices] | |
| plane_ids = np.zeros(len(surface_indices), dtype=np.uint8) | |
| quality = app.analyze_surface_uv_quality(surface, surface_indices, uv, plane_ids) | |
| self.assertFalse(quality["surfaceUvEnabled"]) | |
| self.assertEqual(quality["textureMappingMode"], "regularized-floor-plane") | |
| self.assertEqual(quality["textureMappingReason"], "noisy-surface-uv") | |
| def test_surface_uv_quality_rejects_partial_invalid_uvs(self): | |
| height, width = 60, 80 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| surface = np.ones((height, width), dtype=np.uint8) | |
| surface_indices = np.flatnonzero(surface.ravel()).astype(np.uint32) | |
| uv = np.column_stack(( | |
| xx.ravel()[surface_indices] * 0.02, | |
| yy.ravel()[surface_indices] * 0.02, | |
| )).astype(np.float32) | |
| uv[:80] = np.nan | |
| plane_ids = np.zeros(len(surface_indices), dtype=np.uint8) | |
| quality = app.analyze_surface_uv_quality(surface, surface_indices, uv, plane_ids) | |
| self.assertFalse(quality["surfaceUvEnabled"]) | |
| self.assertEqual(quality["textureMappingReason"], "incomplete-surface-uv") | |
| def test_surface_edge_coverage_closes_small_boundary_gaps(self): | |
| surface = np.zeros((80, 120), dtype=np.uint8) | |
| surface[40:75, 20:100] = 1 | |
| surface[40:48, 55:57] = 0 | |
| protected = np.zeros_like(surface, dtype=bool) | |
| repaired, metadata = app.improve_surface_edge_coverage(surface, protected) | |
| self.assertGreater(metadata["surfacePixelsAfterEdgeFix"], metadata["surfacePixelsBeforeEdgeFix"]) | |
| self.assertEqual(int(repaired[44, 56]), 1) | |
| def test_surface_edge_coverage_respects_protected_pixels(self): | |
| surface = np.zeros((80, 120), dtype=np.uint8) | |
| surface[40:75, 20:100] = 1 | |
| protected = np.zeros_like(surface, dtype=bool) | |
| protected[35:45, 95:110] = True | |
| repaired, _ = app.improve_surface_edge_coverage(surface, protected) | |
| self.assertFalse(repaired[protected].any()) | |
| self.assertEqual(int(repaired[42, 99]), 0) | |
| def test_floor_surface_reaches_wall_and_door_contact_edges(self): | |
| height, width = 80, 120 | |
| floor_id = app.class_ids({"floor"})[0] | |
| wall_id = app.class_ids({"wall"})[0] | |
| door_id = app.class_ids({"door"})[0] | |
| floor_mask = np.zeros((height, width), dtype=np.uint8) | |
| floor_mask[40:75, 15:105] = 1 | |
| seg_map = np.full((height, width), floor_id, dtype=np.uint8) | |
| seg_map[:40, :] = wall_id | |
| seg_map[15:40, 50:70] = door_id | |
| surface, _ = app.build_floor_surface_mask( | |
| floor_mask, | |
| seg_map, | |
| quad=None, | |
| depth=None, | |
| geometry=None, | |
| plane=None, | |
| ) | |
| self.assertTrue(surface[40, 20:100].all()) | |
| self.assertFalse(surface[:40].any()) | |
| def test_foreground_occlusion_does_not_create_floor_halo_below_door(self): | |
| height, width = 80, 120 | |
| floor_id = app.class_ids({"floor"})[0] | |
| door_id = app.class_ids({"door"})[0] | |
| surface = np.zeros((height, width), dtype=np.uint8) | |
| surface[40:75, 15:105] = 1 | |
| seg_map = np.full((height, width), floor_id, dtype=np.uint8) | |
| seg_map[15:40, 50:70] = door_id | |
| occlusion = app.foreground_occlusion_mask(surface, seg_map) | |
| self.assertTrue(occlusion[30:40, 50:70].all()) | |
| self.assertFalse(occlusion[40:, 50:70].any()) | |
| def test_soft_floor_covering_expansion_adds_only_nearby_rug_pixels(self): | |
| surface = np.zeros((80, 120), dtype=np.uint8) | |
| surface[40:75, 20:100] = 1 | |
| rug = np.zeros_like(surface) | |
| rug[45:70, 100:108] = 1 | |
| protected = np.zeros_like(surface, dtype=bool) | |
| original_enabled = app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION | |
| original_ratio = app.SOFT_FLOOR_COVERING_KERNEL_RATIO | |
| original_iterations = app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS | |
| try: | |
| app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION = True | |
| app.SOFT_FLOOR_COVERING_KERNEL_RATIO = 0.008 | |
| app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS = 2 | |
| expanded, metadata = app.expand_surface_over_soft_floor_coverings( | |
| surface, | |
| rug, | |
| protected, | |
| ) | |
| finally: | |
| app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION = original_enabled | |
| app.SOFT_FLOOR_COVERING_KERNEL_RATIO = original_ratio | |
| app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS = original_iterations | |
| self.assertTrue(metadata["softFloorCoveringExpansionApplied"]) | |
| self.assertEqual(int(expanded[50, 100]), 1) | |
| self.assertEqual(int(expanded[50, 107]), 0) | |
| def test_soft_floor_covering_expansion_respects_hard_protected_pixels(self): | |
| surface = np.zeros((80, 120), dtype=np.uint8) | |
| surface[40:75, 20:100] = 1 | |
| rug = np.zeros_like(surface) | |
| rug[45:70, 100:104] = 1 | |
| protected = np.zeros_like(surface, dtype=bool) | |
| protected[45:70, 100:104] = True | |
| expanded, metadata = app.expand_surface_over_soft_floor_coverings( | |
| surface, | |
| rug, | |
| protected, | |
| ) | |
| self.assertFalse(metadata["softFloorCoveringExpansionApplied"]) | |
| self.assertFalse(expanded[protected].any()) | |
| def test_floor_surface_mask_keeps_replaceable_rug_pixels(self): | |
| height, width = 80, 120 | |
| floor_id = app.class_ids({"floor"})[0] | |
| rug_ids = app.class_ids({"rug"}) | |
| self.assertTrue(rug_ids) | |
| rug_id = rug_ids[0] | |
| floor_mask = np.zeros((height, width), dtype=np.uint8) | |
| floor_mask[40:75, 20:108] = 1 | |
| seg_map = np.full((height, width), floor_id, dtype=np.uint8) | |
| seg_map[45:70, 100:108] = rug_id | |
| original_enabled = app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION | |
| original_ratio = app.SOFT_FLOOR_COVERING_KERNEL_RATIO | |
| original_iterations = app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS | |
| try: | |
| app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION = True | |
| app.SOFT_FLOOR_COVERING_KERNEL_RATIO = 0.008 | |
| app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS = 2 | |
| surface, metadata = app.build_floor_surface_mask( | |
| floor_mask, | |
| seg_map, | |
| quad=None, | |
| depth=None, | |
| geometry=None, | |
| plane=None, | |
| ) | |
| finally: | |
| app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION = original_enabled | |
| app.SOFT_FLOOR_COVERING_KERNEL_RATIO = original_ratio | |
| app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS = original_iterations | |
| self.assertFalse(metadata["softFloorCoveringExpansionApplied"]) | |
| self.assertEqual(int(surface[50, 100]), 1) | |
| self.assertEqual(int(surface[50, 107]), 1) | |
| def test_floor_surface_mask_does_not_readd_off_plane_edge_pixels(self): | |
| height, width = 80, 120 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| floor_mask = np.zeros((height, width), dtype=np.uint8) | |
| floor_mask[45:74, 22:98] = 1 | |
| seg_map = np.full((height, width), app.class_ids({"floor"})[0], dtype=np.uint8) | |
| points = np.zeros((height, width, 3), dtype=np.float32) | |
| points[:, :, 0] = (xx - width / 2) / 40.0 | |
| points[:, :, 2] = (yy - 45) / 40.0 + 1.0 | |
| points[:, :, 1] = 0.0 | |
| points[:45, :, 1] = 0.5 | |
| normals = np.zeros_like(points) | |
| normals[:, :, 1] = 1.0 | |
| geometry = { | |
| "provider": "synthetic", | |
| "points": points, | |
| "normals": normals, | |
| "validMask": np.ones((height, width), dtype=bool), | |
| "intrinsics": np.eye(3, dtype=np.float32), | |
| } | |
| plane = { | |
| "planeNormal": [0.0, 1.0, 0.0], | |
| "planeOrigin": [0.0, 0.0, 0.0], | |
| } | |
| original_filter = app.ENABLE_GEOMETRY_SURFACE_FILTER | |
| original_min = app.SURFACE_MIN_PLANE_PIXELS | |
| original_distance = app.SURFACE_PLANE_DISTANCE_METERS | |
| try: | |
| app.ENABLE_GEOMETRY_SURFACE_FILTER = True | |
| app.SURFACE_MIN_PLANE_PIXELS = 250 | |
| app.SURFACE_PLANE_DISTANCE_METERS = 0.12 | |
| surface, metadata = app.build_floor_surface_mask( | |
| floor_mask, | |
| seg_map, | |
| quad=None, | |
| depth=None, | |
| geometry=geometry, | |
| plane=plane, | |
| ) | |
| finally: | |
| app.ENABLE_GEOMETRY_SURFACE_FILTER = original_filter | |
| app.SURFACE_MIN_PLANE_PIXELS = original_min | |
| app.SURFACE_PLANE_DISTANCE_METERS = original_distance | |
| self.assertGreater(metadata["surfacePixelsAfterEdgeFix"], metadata["surfacePixelsBeforeEdgeFix"]) | |
| self.assertTrue(metadata["postEdgeGeometrySurfaceFilterApplied"]) | |
| self.assertEqual(int(surface[:45].sum()), 0) | |
| def test_floor_surface_mask_filters_off_plane_rug_expansion(self): | |
| height, width = 80, 120 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| floor_id = app.class_ids({"floor"})[0] | |
| rug_id = app.class_ids({"rug"})[0] | |
| floor_mask = np.zeros((height, width), dtype=np.uint8) | |
| floor_mask[45:74, 22:98] = 1 | |
| seg_map = np.full((height, width), floor_id, dtype=np.uint8) | |
| seg_map[42:45, 40:52] = rug_id | |
| points = np.zeros((height, width, 3), dtype=np.float32) | |
| points[:, :, 0] = (xx - width / 2) / 40.0 | |
| points[:, :, 2] = (yy - 45) / 40.0 + 1.0 | |
| points[:, :, 1] = 0.0 | |
| points[:45, :, 1] = 0.5 | |
| normals = np.zeros_like(points) | |
| normals[:, :, 1] = 1.0 | |
| geometry = { | |
| "provider": "synthetic", | |
| "points": points, | |
| "normals": normals, | |
| "validMask": np.ones((height, width), dtype=bool), | |
| "intrinsics": np.eye(3, dtype=np.float32), | |
| } | |
| plane = { | |
| "planeNormal": [0.0, 1.0, 0.0], | |
| "planeOrigin": [0.0, 0.0, 0.0], | |
| } | |
| original_filter = app.ENABLE_GEOMETRY_SURFACE_FILTER | |
| original_min = app.SURFACE_MIN_PLANE_PIXELS | |
| original_distance = app.SURFACE_PLANE_DISTANCE_METERS | |
| original_enabled = app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION | |
| original_ratio = app.SOFT_FLOOR_COVERING_KERNEL_RATIO | |
| original_iterations = app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS | |
| try: | |
| app.ENABLE_GEOMETRY_SURFACE_FILTER = True | |
| app.SURFACE_MIN_PLANE_PIXELS = 250 | |
| app.SURFACE_PLANE_DISTANCE_METERS = 0.12 | |
| app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION = True | |
| app.SOFT_FLOOR_COVERING_KERNEL_RATIO = 0.008 | |
| app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS = 2 | |
| surface, metadata = app.build_floor_surface_mask( | |
| floor_mask, | |
| seg_map, | |
| quad=None, | |
| depth=None, | |
| geometry=geometry, | |
| plane=plane, | |
| ) | |
| finally: | |
| app.ENABLE_GEOMETRY_SURFACE_FILTER = original_filter | |
| app.SURFACE_MIN_PLANE_PIXELS = original_min | |
| app.SURFACE_PLANE_DISTANCE_METERS = original_distance | |
| app.ENABLE_SOFT_FLOOR_COVERING_EXPANSION = original_enabled | |
| app.SOFT_FLOOR_COVERING_KERNEL_RATIO = original_ratio | |
| app.SOFT_FLOOR_COVERING_DILATION_ITERATIONS = original_iterations | |
| self.assertFalse(metadata["softFloorCoveringExpansionApplied"]) | |
| self.assertTrue(metadata["postSoftFloorCoveringGeometrySurfaceFilterApplied"]) | |
| self.assertEqual(int(surface[:45].sum()), 0) | |
| def test_point_map_produces_moge_transform(self): | |
| height, width = 360, 640 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| mask = (yy > 220).astype(np.uint8) | |
| focal = max(height, width) * 0.95 | |
| depth = np.full((height, width), 20.0, dtype=np.float32) | |
| depth[mask > 0] = 1.6 * focal / (yy[mask > 0] - (height - 1) * 0.5) | |
| points = np.dstack(( | |
| (xx - (width - 1) * 0.5) * depth / focal, | |
| (yy - (height - 1) * 0.5) * depth / focal, | |
| depth, | |
| )).astype(np.float32) | |
| normals = np.zeros_like(points) | |
| normals[:, :, 1] = 1.0 | |
| result = app.fit_metric_floor_transform( | |
| mask, | |
| points, | |
| valid_mask=mask > 0, | |
| normals_map=normals, | |
| intrinsics=np.array([[focal, 0, width / 2], [0, focal, height / 2], [0, 0, 1]], dtype=np.float32), | |
| provider="moge-2", | |
| ) | |
| self.assertIsNotNone(result) | |
| self.assertEqual(result["geometryProvider"], "moge-2") | |
| self.assertEqual(len(result["cameraIntrinsics"]), 9) | |
| self.assertGreater(result["geometryConfidence"], 0.35) | |
| def test_metric_depth_produces_meter_space_transform(self): | |
| height, width = 360, 640 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| mask = (yy > 220).astype(np.uint8) | |
| focal = max(height, width) * 0.95 | |
| depth = np.full((height, width), 20.0, dtype=np.float32) | |
| depth[mask > 0] = 1.6 * focal / (yy[mask > 0] - (height - 1) * 0.5) | |
| original_name = app.DEPTH_MODEL_NAME | |
| app.DEPTH_MODEL_NAME = "depth-anything/Depth-Anything-V2-Metric-Indoor-Large-hf" | |
| try: | |
| result = app.estimate_metric_floor_transform(mask, depth) | |
| finally: | |
| app.DEPTH_MODEL_NAME = original_name | |
| self.assertIsNotNone(result) | |
| self.assertEqual(len(result["floorTransform"]), 9) | |
| self.assertGreater(result["geometryConfidence"], 0.35) | |
| self.assertLess(result["fitResidualMeters"], 0.18) | |
| def test_relative_depth_is_normalized_for_backprojection(self): | |
| height, width = 360, 640 | |
| yy, _ = np.mgrid[0:height, 0:width] | |
| mask = (yy > 220).astype(np.uint8) | |
| depth = np.zeros((height, width), dtype=np.float32) | |
| depth[mask > 0] = (yy[mask > 0] - 220) / float(height - 220) | |
| depth_for_points, depth_scale = app.prepare_depth_for_backprojection(depth, mask, False) | |
| intrinsics = app.default_camera_intrinsics(width, height) | |
| points = app.backproject_depth(depth_for_points, intrinsics) | |
| self.assertEqual(depth_scale, "relative-normalized") | |
| self.assertEqual(points.shape, (height, width, 3)) | |
| self.assertTrue(np.isfinite(points[mask > 0]).all()) | |
| def test_plane_fit_flag_preserves_homography_fallback(self): | |
| mask = np.ones((100, 100), dtype=np.uint8) | |
| depth = np.ones((100, 100), dtype=np.float32) | |
| original_flag = app.ENABLE_PLANE_FIT | |
| app.ENABLE_PLANE_FIT = False | |
| try: | |
| result = app.estimate_metric_floor_transform(mask, depth) | |
| finally: | |
| app.ENABLE_PLANE_FIT = original_flag | |
| self.assertIsNone(result) | |
| def test_multi_plane_surface_mapping_assigns_floor_and_wall(self): | |
| height, width = 120, 120 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| mask = np.zeros((height, width), dtype=np.uint8) | |
| mask[60:, :] = 1 | |
| mask[20:60, 30:90] = 1 | |
| points = np.zeros((height, width, 3), dtype=np.float32) | |
| normals = np.zeros_like(points) | |
| floor = mask.astype(bool) & (yy >= 60) | |
| points[floor, 0] = (xx[floor] - width / 2) / 40.0 | |
| points[floor, 1] = 1.0 | |
| points[floor, 2] = (yy[floor] - 60) / 40.0 + 1.0 | |
| normals[floor] = np.array([0.0, 1.0, 0.0], dtype=np.float32) | |
| wall = mask.astype(bool) & (yy < 60) | |
| points[wall, 0] = (xx[wall] - width / 2) / 40.0 | |
| points[wall, 1] = (60 - yy[wall]) / 40.0 | |
| points[wall, 2] = 1.0 | |
| normals[wall] = np.array([0.0, 0.0, 1.0], dtype=np.float32) | |
| geometry = { | |
| "provider": "synthetic", | |
| "points": points, | |
| "normals": normals, | |
| "validMask": mask > 0, | |
| "intrinsics": np.eye(3, dtype=np.float32), | |
| } | |
| original_min = app.SURFACE_MIN_PLANE_PIXELS | |
| app.SURFACE_MIN_PLANE_PIXELS = 1000 | |
| try: | |
| surface_indices = np.flatnonzero(mask.ravel()).astype(np.uint32) | |
| mapping = app.build_surface_uv_mapping(mask, surface_indices, geometry) | |
| finally: | |
| app.SURFACE_MIN_PLANE_PIXELS = original_min | |
| self.assertIsNotNone(mapping) | |
| self.assertGreaterEqual(len(mapping["planes"]), 2) | |
| self.assertEqual(mapping["uv"].shape, (len(surface_indices), 2)) | |
| self.assertTrue(np.isfinite(mapping["uv"]).all()) | |
| self.assertGreaterEqual(len(set(mapping["planeIds"].tolist())), 2) | |
| self.assertTrue(any(plane["isFloor"] for plane in mapping["planes"])) | |
| def test_surface_mapping_falls_back_when_normals_are_missing(self): | |
| height, width = 120, 120 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| mask = (yy >= 40).astype(np.uint8) | |
| points = np.zeros((height, width, 3), dtype=np.float32) | |
| floor = mask.astype(bool) | |
| points[floor, 0] = (xx[floor] - width / 2) / 45.0 | |
| points[floor, 1] = 1.0 | |
| points[floor, 2] = (yy[floor] - 40) / 45.0 + 1.0 | |
| normals = np.zeros_like(points) | |
| geometry = { | |
| "provider": "synthetic", | |
| "points": points, | |
| "normals": normals, | |
| "validMask": mask > 0, | |
| "intrinsics": np.eye(3, dtype=np.float32), | |
| } | |
| original_min = app.SURFACE_MIN_PLANE_PIXELS | |
| app.SURFACE_MIN_PLANE_PIXELS = 1000 | |
| try: | |
| surface_indices = np.flatnonzero(mask.ravel()).astype(np.uint32) | |
| mapping = app.build_surface_uv_mapping(mask, surface_indices, geometry) | |
| finally: | |
| app.SURFACE_MIN_PLANE_PIXELS = original_min | |
| self.assertIsNotNone(mapping) | |
| self.assertGreaterEqual(len(mapping["planes"]), 1) | |
| self.assertFalse(mapping["normalsReliable"]) | |
| self.assertIn("distance-only", {plane["fitMode"] for plane in mapping["planes"]}) | |
| self.assertEqual(mapping["uv"].shape, (len(surface_indices), 2)) | |
| self.assertTrue(np.isfinite(mapping["uv"]).all()) | |
| def test_analyze_floor_direction_route_uses_local_segmentation(self): | |
| height, width = 180, 240 | |
| image = np.full((height, width, 3), 225, dtype=np.uint8) | |
| for y in range(35, height - 20, 32): | |
| image[y:y + 2, 20:width - 20] = 70 | |
| buffer = io.BytesIO() | |
| Image.fromarray(image).save(buffer, format="JPEG") | |
| original_builder = app.build_oneformer_polygon_response | |
| app.build_oneformer_polygon_response = lambda *_args, **_kwargs: { | |
| "model": "test", | |
| "task": "panoptic", | |
| "width": width, | |
| "height": height, | |
| "segments": [ | |
| { | |
| "label": "floor", | |
| "confidence": 0.98, | |
| "polygons": [ | |
| { | |
| "points": [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], | |
| "bbox": [0, 0, width, height], | |
| } | |
| ], | |
| } | |
| ], | |
| } | |
| try: | |
| client = TestClient(app.app) | |
| response = client.post( | |
| "/analyze-floor-direction", | |
| data={"include_overlay": "true"}, | |
| files={"file": ("room.jpg", buffer.getvalue(), "image/jpeg")}, | |
| ) | |
| finally: | |
| app.build_oneformer_polygon_response = original_builder | |
| self.assertEqual(response.status_code, 200) | |
| payload = response.json() | |
| self.assertIsNotNone(payload["angle_degrees"]) | |
| self.assertEqual(payload["segmentation"]["label"], "floor") | |
| self.assertEqual(payload["segmentation"]["source_path"], "root.segments[floor]") | |
| self.assertGreater(payload["line_count"], 0) | |
| self.assertTrue(payload["overlay_image_base64"].startswith("data:image/png;base64,")) | |
| def test_floor_direction_prefers_repeated_grout_family_over_single_long_line(self): | |
| lines = [ | |
| floor_direction.DetectedLine(30, 80, 110, 80, 0.0, 80.0), | |
| floor_direction.DetectedLine(30, 140, 110, 140, 0.0, 80.0), | |
| floor_direction.DetectedLine(30, 200, 110, 200, 0.0, 80.0), | |
| floor_direction.DetectedLine(30, 260, 110, 260, 0.0, 80.0), | |
| floor_direction.DetectedLine(310, 20, 310, 380, 90.0, 360.0), | |
| ] | |
| orientation = floor_direction.dominant_orientation(lines, image_shape=(420, 360)) | |
| self.assertIsNotNone(orientation) | |
| angle_degrees, dominant_lines, _peak_weight_ratio, _concentration, orthogonal_ratio = orientation | |
| self.assertLess(floor_direction.angular_distance_degrees(angle_degrees, 0.0), 1.0) | |
| self.assertEqual(len(dominant_lines), 4) | |
| self.assertGreater(orthogonal_ratio, 1.0) | |
| def test_floor_direction_uses_original_image_grout_lines(self): | |
| height, width = 220, 300 | |
| image_bgr = np.full((height, width, 3), 190, dtype=np.uint8) | |
| grout_angle = 12.0 | |
| slope = np.tan(np.deg2rad(grout_angle)) | |
| for y in range(34, height - 32, 34): | |
| start = (20, y) | |
| end = (width - 24, int(round(y + (width - 44) * slope))) | |
| cv2.line(image_bgr, start, end, (58, 58, 58), 2, cv2.LINE_AA) | |
| cv2.line(image_bgr, (252, 18), (252, height - 18), (42, 42, 42), 5, cv2.LINE_AA) | |
| segmentation = floor_direction.FloorSegmentation( | |
| label="floor", | |
| confidence=1.0, | |
| polygons=[ | |
| floor_direction.FloorPolygon( | |
| points=[(0, 0), (width - 1, 0), (width - 1, height - 1), (0, height - 1)] | |
| ) | |
| ], | |
| source_width=width, | |
| source_height=height, | |
| ) | |
| result = floor_direction.analyze_floor_direction(image_bgr, segmentation) | |
| self.assertIsNotNone(result.angle_degrees) | |
| self.assertLess( | |
| floor_direction.angular_distance_degrees(result.angle_degrees or 0.0, grout_angle), | |
| 5.0, | |
| ) | |
| self.assertGreaterEqual(result.dominant_line_count, 4) | |
| def test_rectified_grout_rotation_snaps_to_the_tile_axes(self): | |
| lines = [ | |
| floor_direction.DetectedLine(20, 30, 180, 30, 0.0, 160.0), | |
| floor_direction.DetectedLine(20, 70, 180, 70, 0.0, 160.0), | |
| floor_direction.DetectedLine(20, 110, 180, 110, 0.0, 160.0), | |
| floor_direction.DetectedLine(20, 150, 180, 150, 0.0, 160.0), | |
| ] | |
| rotation_degrees = 14.0 | |
| radians = np.deg2rad(rotation_degrees) | |
| render_transform = [ | |
| np.cos(radians), -np.sin(radians), 0.0, | |
| np.sin(radians), np.cos(radians), 0.0, | |
| 0.0, 0.0, 1.0, | |
| ] | |
| render_rotation = floor_direction.estimate_rectified_grout_rotation( | |
| lines, | |
| render_transform=render_transform, | |
| ) | |
| self.assertEqual(render_rotation, 0.0) | |
| def test_surface_uv_grout_rotation_uses_renderer_coordinates(self): | |
| height, width = 180, 240 | |
| lines = [ | |
| floor_direction.DetectedLine(20, 30, 220, 30, 0.0, 200.0), | |
| floor_direction.DetectedLine(20, 65, 220, 65, 0.0, 200.0), | |
| floor_direction.DetectedLine(20, 100, 220, 100, 0.0, 200.0), | |
| floor_direction.DetectedLine(20, 135, 220, 135, 0.0, 200.0), | |
| ] | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| angle_radians = np.deg2rad(23.0) | |
| surface_uv = np.column_stack(( | |
| xx.ravel() * np.cos(angle_radians) - yy.ravel() * np.sin(angle_radians), | |
| xx.ravel() * np.sin(angle_radians) + yy.ravel() * np.cos(angle_radians), | |
| )).astype(np.float32) | |
| surface_indices = np.arange(height * width, dtype=np.uint32) | |
| surface_plane_ids = np.zeros(height * width, dtype=np.uint8) | |
| render_rotation = floor_direction.estimate_surface_uv_grout_rotation( | |
| lines, | |
| surface_uv=surface_uv, | |
| surface_indices=surface_indices, | |
| surface_plane_ids=surface_plane_ids, | |
| image_shape=(height, width), | |
| ) | |
| self.assertIsNotNone(render_rotation) | |
| self.assertLess(floor_direction.angular_distance_degrees(render_rotation or 0.0, 23.0), 1.0) | |
| def test_surface_uv_rotation_prefers_regular_grout_lattice_over_long_irregular_edges(self): | |
| lines = [ | |
| floor_direction.DetectedLine(40, y, 280, y, 0.0, 240.0) | |
| for y in (80, 125, 170, 215, 260) | |
| ] | |
| for offset in (0, 17, 49, 91, 143, 208, 260, 313): | |
| radians = np.deg2rad(45.0) | |
| normal = np.array([-np.sin(radians), np.cos(radians)]) | |
| direction = np.array([np.cos(radians), np.sin(radians)]) | |
| center = np.array([500.0, 350.0]) + normal * offset | |
| start, end = center - direction * 130.0, center + direction * 130.0 | |
| lines.append( | |
| floor_direction.DetectedLine( | |
| float(start[0]), | |
| float(start[1]), | |
| float(end[0]), | |
| float(end[1]), | |
| 45.0, | |
| 260.0, | |
| ) | |
| ) | |
| angle = floor_direction.estimate_repeated_grout_orientation( | |
| lines, | |
| image_shape=(700, 800), | |
| ) | |
| self.assertIsNotNone(angle) | |
| self.assertLess(floor_direction.angular_distance_degrees(angle or 0.0, 0.0), 1.0) | |
| def test_surface_uv_rotation_rectifies_source_before_detecting_grout(self): | |
| height, width = 180, 240 | |
| image = np.full((height, width, 3), 225, dtype=np.uint8) | |
| for y in range(30, height - 20, 30): | |
| cv2.line(image, (20, y), (width - 20, y), (55, 55, 55), 2, cv2.LINE_AA) | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| surface_uv = np.column_stack((xx.ravel(), yy.ravel())).astype(np.float32) | |
| surface_indices = np.arange(height * width, dtype=np.uint32) | |
| source_lines = [ | |
| floor_direction.DetectedLine(20, 30, width - 20, 30, 0.0, width - 40.0), | |
| floor_direction.DetectedLine(20, 60, width - 20, 60, 0.0, width - 40.0), | |
| floor_direction.DetectedLine(20, 90, width - 20, 90, 0.0, width - 40.0), | |
| floor_direction.DetectedLine(20, 120, width - 20, 120, 0.0, width - 40.0), | |
| ] | |
| angle = floor_direction.estimate_surface_uv_grout_rotation( | |
| source_lines, | |
| image_bgr=image, | |
| surface_uv=surface_uv, | |
| surface_indices=surface_indices, | |
| surface_plane_ids=np.zeros(height * width, dtype=np.uint8), | |
| image_shape=(height, width), | |
| ) | |
| self.assertIsNotNone(angle) | |
| self.assertLess(floor_direction.angular_distance_degrees(angle or 0.0, 0.0), 2.0) | |
| def test_surface_uv_rotation_handles_perspective_converging_grout(self): | |
| floor_height, floor_width = 180, 240 | |
| source_height, source_width = 250, 320 | |
| floor = np.full((floor_height, floor_width, 3), 220, dtype=np.uint8) | |
| for y in range(28, floor_height - 18, 30): | |
| cv2.line(floor, (12, y), (floor_width - 12, y), (45, 45, 45), 2, cv2.LINE_AA) | |
| source_corners = np.array([[62, 42], [278, 68], [248, 218], [24, 194]], dtype=np.float32) | |
| floor_corners = np.array( | |
| [[0, 0], [floor_width - 1, 0], [floor_width - 1, floor_height - 1], [0, floor_height - 1]], | |
| dtype=np.float32, | |
| ) | |
| uv_to_source = cv2.getPerspectiveTransform(floor_corners, source_corners) | |
| source = cv2.warpPerspective(floor, uv_to_source, (source_width, source_height)) | |
| surface_mask = np.zeros((source_height, source_width), dtype=np.uint8) | |
| cv2.fillConvexPoly(surface_mask, source_corners.astype(np.int32), 1) | |
| yy, xx = np.mgrid[0:source_height, 0:source_width] | |
| source_points = np.column_stack((xx.ravel(), yy.ravel())).astype(np.float32).reshape(1, -1, 2) | |
| source_to_uv = np.linalg.inv(uv_to_source) | |
| uv_all = cv2.perspectiveTransform(source_points, source_to_uv)[0] | |
| surface_indices = np.flatnonzero(surface_mask.ravel()).astype(np.uint32) | |
| surface_uv = uv_all[surface_indices] | |
| distractor_lines = [ | |
| floor_direction.DetectedLine(30, y, 290, y + 45, 10.0, 264.0) | |
| for y in (20, 54, 101, 158, 215) | |
| ] | |
| angle = floor_direction.estimate_surface_uv_grout_rotation( | |
| distractor_lines, | |
| image_bgr=source, | |
| surface_uv=surface_uv, | |
| surface_indices=surface_indices, | |
| surface_plane_ids=np.zeros(len(surface_indices), dtype=np.uint8), | |
| image_shape=(source_height, source_width), | |
| ) | |
| self.assertIsNotNone(angle) | |
| self.assertLess(floor_direction.angular_distance_degrees(angle or 0.0, 0.0), 3.0) | |
| def test_floor_direction_metadata_prefers_surface_uv_rotation(self): | |
| height, width = 180, 240 | |
| image = np.full((height, width, 3), 225, dtype=np.uint8) | |
| for y in range(30, height - 20, 30): | |
| image[y:y + 2, 20:width - 20] = 65 | |
| yy, xx = np.mgrid[0:height, 0:width] | |
| angle_radians = np.deg2rad(23.0) | |
| surface_uv = np.column_stack(( | |
| xx.ravel() * np.cos(angle_radians) - yy.ravel() * np.sin(angle_radians), | |
| xx.ravel() * np.sin(angle_radians) + yy.ravel() * np.cos(angle_radians), | |
| )).astype(np.float32) | |
| surface_indices = np.arange(height * width, dtype=np.uint32) | |
| surface_plane_ids = np.zeros(height * width, dtype=np.uint8) | |
| metadata = app.analyze_floor_direction_for_surface_mask( | |
| image, | |
| np.ones((height, width), dtype=np.uint8), | |
| surface_uv=surface_uv, | |
| surface_indices=surface_indices, | |
| surface_plane_ids=surface_plane_ids, | |
| ) | |
| self.assertLess( | |
| floor_direction.angular_distance_degrees(metadata["renderAngleDegrees"] or 0.0, 23.0), | |
| 1.0, | |
| ) | |
| self.assertIn("grout_direction_projected_to_surface_uv", metadata["warnings"]) | |
| def test_direction_finder_detects_multi_region_material_axis(self): | |
| size = 320 | |
| gray = np.full((size, size), 205, dtype=np.uint8) | |
| angle_degrees = 27.0 | |
| radians = np.deg2rad(angle_degrees) | |
| direction = np.array([np.cos(radians), np.sin(radians)]) | |
| normal = np.array([-direction[1], direction[0]]) | |
| center = np.array([size / 2, size / 2]) | |
| for offset in range(-360, 361, 30): | |
| line_center = center + normal * offset | |
| start = np.rint(line_center - direction * 300).astype(int) | |
| end = np.rint(line_center + direction * 300).astype(int) | |
| cv2.line(gray, tuple(start), tuple(end), 45, 3, cv2.LINE_AA) | |
| cue = direction_finder.estimate_material_cue( | |
| gray, | |
| np.full_like(gray, 255), | |
| ) | |
| self.assertIsNotNone(cue) | |
| self.assertLess( | |
| direction_finder.angular_distance(cue.angle_degrees, angle_degrees), | |
| 4.0, | |
| ) | |
| self.assertGreaterEqual(cue.region_support, 3) | |
| def test_direction_finder_rejects_blank_material(self): | |
| cue = direction_finder.estimate_material_cue( | |
| np.full((240, 320), 180, dtype=np.uint8), | |
| np.full((240, 320), 255, dtype=np.uint8), | |
| ) | |
| self.assertIsNone(cue) | |
| def test_direction_finder_uses_wall_normals_for_manhattan_axis(self): | |
| height, width = 240, 320 | |
| wall_mask = np.ones((height, width), dtype=np.uint8) | |
| normals = np.zeros((height, width, 3), dtype=np.float32) | |
| radians = np.deg2rad(20.0) | |
| normals[:, :, 0] = np.cos(radians) | |
| normals[:, :, 2] = np.sin(radians) | |
| cue = direction_finder.estimate_wall_normal_cue( | |
| normals, | |
| np.ones((height, width), dtype=bool), | |
| wall_mask, | |
| floor_normal=np.array([0.0, 1.0, 0.0]), | |
| render_u_axis=np.array([1.0, 0.0, 0.0]), | |
| render_v_axis=np.array([0.0, 0.0, 1.0]), | |
| intrinsics=np.eye(3), | |
| render_transform=np.eye(3), | |
| ) | |
| self.assertIsNotNone(cue) | |
| self.assertLess( | |
| direction_finder.angular_distance(cue.angle_degrees, 20.0, period=90.0), | |
| 1.0, | |
| ) | |
| self.assertGreater(cue.confidence, 0.7) | |
| self.assertEqual(cue.ambiguity_degrees, 90.0) | |
| def test_direction_finder_uses_room_architectural_lines(self): | |
| height, width = 240, 320 | |
| image = np.full((height, width, 3), 220, dtype=np.uint8) | |
| for y in (30, 75, 120, 165, 210): | |
| cv2.line(image, (10, y), (width - 10, y), (30, 30, 30), 3, cv2.LINE_AA) | |
| intrinsics = np.array( | |
| [[300.0, 0.0, width / 2], [0.0, 300.0, height / 2], [0.0, 0.0, 1.0]], | |
| dtype=np.float64, | |
| ) | |
| cue = direction_finder.estimate_architectural_line_cue( | |
| image, | |
| np.ones((height, width), dtype=np.uint8), | |
| floor_normal=np.array([0.0, 1.0, 0.0]), | |
| render_u_axis=np.array([1.0, 0.0, 0.0]), | |
| render_v_axis=np.array([0.0, 0.0, 1.0]), | |
| intrinsics=intrinsics, | |
| render_transform=np.eye(3), | |
| ) | |
| self.assertIsNotNone(cue) | |
| self.assertLess( | |
| direction_finder.angular_distance(cue.angle_degrees, 0.0, period=90.0), | |
| 1.0, | |
| ) | |
| self.assertGreater(cue.confidence, 0.7) | |
| self.assertGreaterEqual(cue.region_support, 3) | |
| def test_direction_finder_rectifies_perspective_material_before_scoring(self): | |
| floor_height, floor_width = 190, 250 | |
| source_height, source_width = 260, 340 | |
| floor = np.full((floor_height, floor_width, 3), 210, dtype=np.uint8) | |
| angle_degrees = 24.0 | |
| radians = np.deg2rad(angle_degrees) | |
| direction = np.array([np.cos(radians), np.sin(radians)]) | |
| normal = np.array([-direction[1], direction[0]]) | |
| center = np.array([floor_width / 2, floor_height / 2]) | |
| for offset in range(-320, 321, 28): | |
| line_center = center + normal * offset | |
| start = np.rint(line_center - direction * 280).astype(int) | |
| end = np.rint(line_center + direction * 280).astype(int) | |
| cv2.line(floor, tuple(start), tuple(end), (45, 45, 45), 3, cv2.LINE_AA) | |
| source_corners = np.array( | |
| [[72, 42], [290, 64], [310, 235], [28, 218]], | |
| dtype=np.float32, | |
| ) | |
| floor_corners = np.array( | |
| [[0, 0], [floor_width - 1, 0], [floor_width - 1, floor_height - 1], [0, floor_height - 1]], | |
| dtype=np.float32, | |
| ) | |
| uv_to_source = cv2.getPerspectiveTransform(floor_corners, source_corners) | |
| source = cv2.warpPerspective(floor, uv_to_source, (source_width, source_height)) | |
| surface_mask = np.zeros((source_height, source_width), dtype=np.uint8) | |
| cv2.fillConvexPoly(surface_mask, source_corners.astype(np.int32), 1) | |
| yy, xx = np.mgrid[0:source_height, 0:source_width] | |
| source_points = np.column_stack((xx.ravel(), yy.ravel())).astype(np.float32).reshape(1, -1, 2) | |
| source_to_uv = np.linalg.inv(uv_to_source) | |
| all_uv = cv2.perspectiveTransform(source_points, source_to_uv)[0] | |
| surface_indices = np.flatnonzero(surface_mask.ravel()).astype(np.uint32) | |
| surface_uv = all_uv[surface_indices] | |
| result = direction_finder.find_floor_direction( | |
| source, | |
| surface_mask, | |
| wall_mask=np.zeros_like(surface_mask), | |
| structure_mask=np.zeros_like(surface_mask), | |
| normals_map=None, | |
| geometry_valid_mask=None, | |
| intrinsics=np.eye(3), | |
| floor_normal=None, | |
| render_u_axis=None, | |
| render_v_axis=None, | |
| render_transform=source_to_uv, | |
| surface_uv=surface_uv, | |
| surface_indices=surface_indices, | |
| surface_plane_ids=np.zeros(len(surface_indices), dtype=np.uint8), | |
| ) | |
| self.assertFalse(result["needsUserDirection"]) | |
| self.assertLess( | |
| direction_finder.angular_distance(result["renderAngleDegrees"], angle_degrees), | |
| 4.0, | |
| ) | |
| self.assertEqual(result["rectification"]["source"], "surface-uv") | |
| def test_direction_finder_marks_unobservable_image(self): | |
| height, width = 180, 240 | |
| surface_mask = np.ones((height, width), dtype=np.uint8) | |
| result = direction_finder.find_floor_direction( | |
| np.full((height, width, 3), 180, dtype=np.uint8), | |
| surface_mask, | |
| wall_mask=np.zeros_like(surface_mask), | |
| structure_mask=np.zeros_like(surface_mask), | |
| normals_map=None, | |
| geometry_valid_mask=None, | |
| intrinsics=np.eye(3), | |
| floor_normal=None, | |
| render_u_axis=None, | |
| render_v_axis=None, | |
| render_transform=np.eye(3), | |
| surface_uv=None, | |
| surface_indices=None, | |
| surface_plane_ids=None, | |
| ) | |
| self.assertEqual(result["source"], "backend-direction-finder-v2") | |
| self.assertEqual(result["directionMethod"], "canonical-render-axis") | |
| self.assertTrue(result["needsUserDirection"]) | |
| self.assertEqual(result["confidence"], 0.0) | |
| def test_floor_direction_parser_does_not_use_non_floor_segment(self): | |
| payload = { | |
| "width": 120, | |
| "height": 80, | |
| "segments": [ | |
| { | |
| "label": "wall", | |
| "confidence": 0.98, | |
| "polygons": [ | |
| { | |
| "points": [[0, 0], [119, 0], [119, 79], [0, 79]], | |
| "bbox": [0, 0, 120, 80], | |
| } | |
| ], | |
| } | |
| ], | |
| } | |
| segmentation = app.parse_floor_segmentation(payload) | |
| self.assertEqual(segmentation.label, "unknown") | |
| self.assertEqual(segmentation.polygons, []) | |
| self.assertIn("segmentation_response_list_without_floor_label", segmentation.warnings) | |
| def test_floor_direction_metadata_from_surface_mask(self): | |
| height, width = 180, 240 | |
| image = np.full((height, width, 3), 225, dtype=np.uint8) | |
| for y in range(35, height - 20, 32): | |
| image[y:y + 2, 20:width - 20] = 70 | |
| surface_mask = np.ones((height, width), dtype=np.uint8) | |
| metadata = app.analyze_floor_direction_for_surface_mask( | |
| image, | |
| surface_mask, | |
| render_transform=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], | |
| ) | |
| self.assertEqual(metadata["source"], "backend-floor-direction") | |
| self.assertIsNotNone(metadata["angleDegrees"]) | |
| self.assertEqual(metadata["renderAngleDegrees"], 0.0) | |
| self.assertEqual(metadata["directionLabel"], "left_to_right") | |
| self.assertGreater(metadata["lineCount"], 0) | |
| self.assertEqual(metadata["segmentation"]["label"], "floor") | |
| if __name__ == "__main__": | |
| unittest.main() | |