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()