room-visualizer / test_floor_geometry.py
Codex Preview Deploy
Preserve observed shadows with intrinsic lighting (eb70748)
c728d08
Raw
History Blame Contribute Delete
48.9 kB
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()