dmytromishkin commited on
Commit
2b3faac
·
1 Parent(s): 6b18df6

2025 update

Browse files
README.md CHANGED
@@ -1,20 +1,77 @@
1
  ---
2
  license: apache-2.0
3
  ---
4
- # HoHo Tools
5
 
6
- Tools and utilities for the [S23DR competition](https://huggingface.co/spaces/usm3d/S23DR) and [HoHo Dataset](https://huggingface.co/datasets/usm3d/usm-training-data)
7
 
8
  ## Installation
9
- ```bash
10
- # pip install over ssh
11
- pip install git+ssh://git@hf.co/usm3d/tools2025.git
12
 
13
- # pip install over http
 
14
  pip install git+http://hf.co/usm3d/tools2025.git
 
15
 
16
- # editable
 
17
  git clone http://hf.co/usm3d/tools2025
18
- cd tools
19
  pip install -e .
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  ```
 
1
  ---
2
  license: apache-2.0
3
  ---
4
+ # HoHo2025 Tools
5
 
6
+ Tools and utilities for the [S23DR-2025 competition](https://huggingface.co/spaces/usm3d/S23DR2025) and [HoHo25k Dataset](https://huggingface.co/datasets/usm3d/hoho25k)
7
 
8
  ## Installation
 
 
 
9
 
10
+ ### pip install over http
11
+ ```bash
12
  pip install git+http://hf.co/usm3d/tools2025.git
13
+ ```
14
 
15
+ or editable
16
+ ```bash
17
  git clone http://hf.co/usm3d/tools2025
18
+ cd tools2025
19
  pip install -e .
20
+ ```
21
+
22
+ ### Usage example
23
+
24
+ ```python
25
+ from datasets import load_dataset
26
+ from hoho2025.vis import plot_all_modalities
27
+ from hoho2025.viz3d import *
28
+
29
+ def read_colmap_rec(colmap_data):
30
+ import pycolmap
31
+ import tempfile,zipfile
32
+ import io
33
+ with tempfile.TemporaryDirectory() as tmpdir:
34
+ with zipfile.ZipFile(io.BytesIO(colmap_data), "r") as zf:
35
+ zf.extractall(tmpdir) # unpacks cameras.txt, images.txt, etc. to tmpdir
36
+ # Now parse with pycolmap
37
+ rec = pycolmap.Reconstruction(tmpdir)
38
+ return rec
39
+
40
+ ds = load_dataset("usm3d/hoho25k", streaming=True, trust_remote_code=True)
41
+ for a in ds['train']:
42
+ break
43
+
44
+ fig, ax = plot_all_modalities(a)
45
+
46
+ ## Now 3d
47
+
48
+ fig3d = init_figure()
49
+ plot_reconstruction(fig3d, read_colmap_rec(a['colmap_binary']))
50
+ plot_wireframe(fig3d, a['wf_vertices'], a['wf_edges'], a['wf_classifications'])
51
+ plot_bpo_cameras_from_entry(fig3d, a)
52
+ fig3d
53
+ ```
54
+
55
+ ## Example wireframe estimation
56
+
57
+ Look in [hoho2025/example_solution.py](hoho2025/example_solution.py)
58
+
59
+ ```python
60
+ from hoho2025.example_solutions import predict_wireframe
61
+ pred_vertices, pred_connections = predict_wireframe(a)
62
+
63
+ fig3d = init_figure()
64
+ plot_reconstruction(fig3d, read_colmap_rec(a['colmap_binary']))
65
+ plot_wireframe(fig3d, pred_vertices, pred_connections, color='rgb(0, 0, 255)')
66
+ fig3d
67
+ ```
68
+
69
+
70
+ And to get the metric
71
+
72
+ ```python
73
+ from hoho2025.metric_helper import hss
74
+
75
+ score = hss(pred_vertices, pred_connections, a['wf_vertices'], a['wf_edges'], vert_thresh=0.5, edge_thresh=0.5)
76
+ print (score)
77
  ```
hoho/wed.py DELETED
@@ -1,107 +0,0 @@
1
- from scipy.spatial.distance import cdist
2
- from scipy.optimize import linear_sum_assignment
3
- import numpy as np
4
-
5
-
6
- def preregister_mean_std(verts_to_transform, target_verts, single_scale=True):
7
- mu_target = target_verts.mean(axis=0)
8
- mu_in = verts_to_transform.mean(axis=0)
9
- std_target = np.std(target_verts, axis=0)
10
- std_in = np.std(verts_to_transform, axis=0)
11
-
12
- if np.any(std_in == 0):
13
- std_in[std_in == 0] = 1
14
- if np.any(std_target == 0):
15
- std_target[std_target == 0] = 1
16
- if np.any(np.isnan(std_in)):
17
- std_in[np.isnan(std_in)] = 1
18
- if np.any(np.isnan(std_target)):
19
- std_target[np.isnan(std_target)] = 1
20
-
21
- if single_scale:
22
- std_target = np.linalg.norm(std_target)
23
- std_in = np.linalg.norm(std_in)
24
-
25
- transformed_verts = (verts_to_transform - mu_in) / std_in
26
- transformed_verts = transformed_verts * std_target + mu_target
27
-
28
- return transformed_verts
29
-
30
-
31
- def update_cv(cv, gt_vertices):
32
- if cv < 0:
33
- diameter = cdist(gt_vertices, gt_vertices).max()
34
- # Cost of adding or deleting a vertex is set to -cv times the diameter of the ground truth wireframe
35
- cv = -cv * diameter
36
- return cv
37
-
38
- def compute_WED(pd_vertices, pd_edges, gt_vertices, gt_edges, cv_ins=-1/2, cv_del=-1/4, ce=1.0, normalized=True, preregister=True, single_scale=True):
39
- '''The function computes the Wireframe Edge Distance (WED) between two graphs.
40
- pd_vertices: list of predicted vertices
41
- pd_edges: list of predicted edges
42
- gt_vertices: list of ground truth vertices
43
- gt_edges: list of ground truth edges
44
- cv_ins: vertex insertion cost: if positive, the cost in centimeters of inserting vertex, if negative, multiplies diameter to compute cost (default is -1/2)
45
- cv_del: vertex deletion cost: if positive, the cost in centimeters of deleting a vertex, if negative, multiplies diameter to compute cost (default is -1/4)
46
- ce: edge cost (multiplier of the edge length for edge deletion and insertion, default is 1.0)
47
- normalized: if True, the WED is normalized by the total length of the ground truth edges
48
- preregister: if True, the predicted vertices have their mean and scale matched to the ground truth vertices
49
- '''
50
-
51
- pd_vertices = np.array(pd_vertices)
52
- gt_vertices = np.array(gt_vertices)
53
- pd_edges = np.array(pd_edges)
54
- gt_edges = np.array(gt_edges)
55
-
56
-
57
- cv_del = update_cv(cv_del, gt_vertices)
58
- cv_ins = update_cv(cv_ins, gt_vertices)
59
-
60
- # Step 0: Prenormalize / preregister
61
- if preregister:
62
- pd_vertices = preregister_mean_std(pd_vertices, gt_vertices, single_scale=single_scale)
63
-
64
-
65
- # Step 1: Bipartite Matching
66
- distances = cdist(pd_vertices, gt_vertices, metric='euclidean')
67
- row_ind, col_ind = linear_sum_assignment(distances)
68
-
69
-
70
- # Step 2: Vertex Translation
71
- translation_costs = np.sum(distances[row_ind, col_ind])
72
-
73
- # Step 3: Vertex Deletion
74
- unmatched_pd_indices = set(range(len(pd_vertices))) - set(row_ind)
75
- deletion_costs = cv_del * len(unmatched_pd_indices)
76
-
77
- # Step 4: Vertex Insertion
78
- unmatched_gt_indices = set(range(len(gt_vertices))) - set(col_ind)
79
- insertion_costs = cv_ins * len(unmatched_gt_indices)
80
-
81
- # Step 5: Edge Deletion and Insertion
82
- updated_pd_edges = [(col_ind[np.where(row_ind == edge[0])[0][0]], col_ind[np.where(row_ind == edge[1])[0][0]]) for edge in pd_edges if len(edge)==2 and edge[0] in row_ind and edge[1] in row_ind]
83
- pd_edges_set = set(map(tuple, [set(edge) for edge in updated_pd_edges]))
84
- gt_edges_set = set(map(tuple, [set(edge) for edge in gt_edges]))
85
-
86
-
87
- # Delete edges not in ground truth
88
- edges_to_delete = pd_edges_set - gt_edges_set
89
-
90
- vert_tf = [np.where(col_ind == v)[0][0] if v in col_ind else 0 for v in range(len(gt_vertices))]
91
- deletion_edge_costs = ce * sum(np.linalg.norm(pd_vertices[vert_tf[edge[0]]] - pd_vertices[vert_tf[edge[1]]]) for edge in edges_to_delete if len(edge) == 2)
92
-
93
-
94
- # Insert missing edges from ground truth
95
- edges_to_insert = gt_edges_set - pd_edges_set
96
- insertion_edge_costs = ce * sum(np.linalg.norm(gt_vertices[edge[0]] - gt_vertices[edge[1]]) for edge in edges_to_insert if len(edge) == 2)
97
-
98
- # Step 6: Calculation of WED
99
- WED = translation_costs + deletion_costs + insertion_costs + deletion_edge_costs + insertion_edge_costs
100
-
101
-
102
- if normalized:
103
- total_length_of_gt_edges = np.linalg.norm((gt_vertices[gt_edges[:, 0]] - gt_vertices[gt_edges[:, 1]]), axis=1).sum()
104
- WED = WED / total_length_of_gt_edges
105
-
106
- # print ("Total length", total_length_of_gt_edges)
107
- return WED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
{hoho → hoho2025}/__init__.py RENAMED
@@ -1,7 +1,5 @@
1
  from .hoho import *
2
  from . import vis
3
- from . import read_write_colmap
4
- from .wed import compute_WED
5
 
6
  import importlib
7
  import sys
 
1
  from .hoho import *
2
  from . import vis
 
 
3
 
4
  import importlib
5
  import sys
{hoho → hoho2025}/color_mappings.py RENAMED
@@ -1,5 +1,3 @@
1
- import numpy as np
2
-
3
  gestalt_color_mapping = {
4
  "unclassified": (215, 62, 138),
5
  "apex": (235, 88, 48),
@@ -185,22 +183,27 @@ ade20k_color_mapping = {
185
  }
186
 
187
 
188
- # edge_colors = np.asarray([(214, 251, 248),
189
- # (13, 94, 47),
190
- # (54, 243, 63),
191
- # (187, 123, 236),
192
- # (162, 162, 32),
193
- # (169, 255, 219),
194
- # (8, 89, 52),
195
- # (85, 27, 65),
196
- # (0, 0, 0)]
197
-
 
198
 
199
- # edge_colors = np.array([[ 54, 243, 63],
200
- # [214, 251, 248],
201
- # [169, 255, 219],
202
- # [ 13, 94, 47],
203
- # [162, 162, 32],
204
- # [187, 123, 236],
205
- # [ 85, 27, 65],
206
- # [ 0, 0, 0]])
 
 
 
 
 
 
 
1
  gestalt_color_mapping = {
2
  "unclassified": (215, 62, 138),
3
  "apex": (235, 88, 48),
 
183
  }
184
 
185
 
186
+ EDGE_CLASSES = {'cornice_return': 0,
187
+ 'cornice_strip': 1,
188
+ 'eave': 2,
189
+ 'flashing': 3,
190
+ 'hip': 4,
191
+ 'rake': 5,
192
+ 'ridge': 6,
193
+ 'step_flashing': 7,
194
+ 'transition_line': 8,
195
+ 'valley': 9}
196
+ EDGE_CLASSES_BY_ID = {v: k for k, v in EDGE_CLASSES.items()}
197
 
198
+ edge_color_mapping = {
199
+ 'cornice_return': (215, 62, 138),
200
+ 'cornice_strip': (235, 88, 48),
201
+ 'eave': (54, 243, 63),
202
+ "flashing": (162, 162, 32),
203
+ 'hip': (8, 89, 52),
204
+ 'rake': (13, 94, 47),
205
+ 'ridge': (214, 251, 248),
206
+ "step_flashing": (169, 255, 219),
207
+ 'transition_line': (200,0,50),
208
+ 'valley': (85, 27, 65),
209
+ }
hoho2025/example_solutions.py ADDED
@@ -0,0 +1,701 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Description: This file contains the handcrafted solution for the task of wireframe reconstruction
2
+ import io
3
+ import tempfile
4
+ import zipfile
5
+ from collections import defaultdict
6
+ from typing import Tuple, List
7
+ import cv2
8
+ import numpy as np
9
+ import pycolmap
10
+ from PIL import Image as PImage
11
+ from scipy.spatial.distance import cdist
12
+
13
+ from hoho2025.color_mappings import ade20k_color_mapping, gestalt_color_mapping
14
+
15
+
16
+ def empty_solution():
17
+ '''Return a minimal valid solution, i.e. 2 vertices and 1 edge.'''
18
+ return np.zeros((2,3)), [(0, 1)]
19
+
20
+
21
+ def read_colmap_rec(colmap_data):
22
+ with tempfile.TemporaryDirectory() as tmpdir:
23
+ with zipfile.ZipFile(io.BytesIO(colmap_data), "r") as zf:
24
+ zf.extractall(tmpdir) # unpacks cameras.txt, images.txt, etc. to tmpdir
25
+ # Now parse with pycolmap
26
+ rec = pycolmap.Reconstruction(tmpdir)
27
+ return rec
28
+
29
+ def convert_entry_to_human_readable(entry):
30
+ out = {}
31
+ for k, v in entry.items():
32
+ if 'colmap' in k:
33
+ out[k] = read_colmap_rec(v)
34
+ elif k in ['wf_vertices', 'wf_edges', 'K', 'R', 't', 'depth']:
35
+ out[k] = np.array(v)
36
+ else:
37
+ out[k]=v
38
+ out['__key__'] = entry['order_id']
39
+ return out
40
+
41
+
42
+ def get_house_mask(ade20k_seg):
43
+ """
44
+ Get a mask of the house in the ADE20K segmentation map.
45
+ """
46
+ house_classes_ade20k = [
47
+ 'wall',
48
+ 'house',
49
+ 'building;edifice',
50
+ 'door;double;door',
51
+ 'windowpane;window',
52
+ ]
53
+ np_seg = np.array(ade20k_seg)
54
+ full_mask = np.zeros(np_seg.shape[:2], dtype=np.uint8)
55
+ for c in house_classes_ade20k:
56
+ color = np.array(ade20k_color_mapping[c])
57
+ mask = cv2.inRange(np_seg, color-0.5, color+0.5)
58
+ full_mask = np.logical_or(full_mask, mask)
59
+ return full_mask
60
+
61
+
62
+ def point_to_segment_dist(pt, seg_p1, seg_p2):
63
+ """
64
+ Computes the Euclidean distance from pt to the line segment p1->p2.
65
+ pt, seg_p1, seg_p2: (x, y) as np.ndarray
66
+ """
67
+ # If both endpoints are the same, just return distance to one of them
68
+ if np.allclose(seg_p1, seg_p2):
69
+ return np.linalg.norm(pt - seg_p1)
70
+ seg_vec = seg_p2 - seg_p1
71
+ pt_vec = pt - seg_p1
72
+ seg_len2 = seg_vec.dot(seg_vec)
73
+ t = max(0, min(1, pt_vec.dot(seg_vec)/seg_len2))
74
+ proj = seg_p1 + t*seg_vec
75
+ return np.linalg.norm(pt - proj)
76
+
77
+
78
+ def get_vertices_and_edges_from_segmentation(gest_seg_np, edge_th=25.0):
79
+ """
80
+ Identify apex and eave-end vertices, then detect lines for eave/ridge/rake/valley.
81
+ For each connected component, we do a line fit with cv2.fitLine, then measure
82
+ segment endpoints more robustly. We then associate apex points that are within
83
+ 'edge_th' of the line segment. We record those apex–apex connections for edges
84
+ if at least 2 apexes lie near the same component line.
85
+ """
86
+ #--------------------------------------------------------------------------------
87
+ # Step A: Collect apex and eave_end vertices
88
+ #--------------------------------------------------------------------------------
89
+ if not isinstance(gest_seg_np, np.ndarray):
90
+ gest_seg_np = np.array(gest_seg_np)
91
+ vertices = []
92
+ # Apex
93
+ apex_color = np.array(gestalt_color_mapping['apex'])
94
+ apex_mask = cv2.inRange(gest_seg_np, apex_color-0.5, apex_color+0.5)
95
+ if apex_mask.sum() > 0:
96
+ output = cv2.connectedComponentsWithStats(apex_mask, 8, cv2.CV_32S)
97
+ (numLabels, labels, stats, centroids) = output
98
+ stats, centroids = stats[1:], centroids[1:] # skip background
99
+ for i in range(numLabels-1):
100
+ vert = {"xy": centroids[i], "type": "apex"}
101
+ vertices.append(vert)
102
+
103
+ # Eave end
104
+ eave_end_color = np.array(gestalt_color_mapping['eave_end_point'])
105
+ eave_end_mask = cv2.inRange(gest_seg_np, eave_end_color-0.5, eave_end_color+0.5)
106
+ if eave_end_mask.sum() > 0:
107
+ output = cv2.connectedComponentsWithStats(eave_end_mask, 8, cv2.CV_32S)
108
+ (numLabels, labels, stats, centroids) = output
109
+ stats, centroids = stats[1:], centroids[1:]
110
+ for i in range(numLabels-1):
111
+ vert = {"xy": centroids[i], "type": "eave_end_point"}
112
+ vertices.append(vert)
113
+
114
+ # Consolidate apex points as array:
115
+ apex_pts = []
116
+ apex_idx_map = [] # keep track of index in 'vertices'
117
+ for idx, v in enumerate(vertices):
118
+ apex_pts.append(v['xy'])
119
+ apex_idx_map.append(idx)
120
+ apex_pts = np.array(apex_pts)
121
+
122
+ connections = []
123
+ edge_classes = ['eave', 'ridge', 'rake', 'valley']
124
+ for edge_class in edge_classes:
125
+ edge_color = np.array(gestalt_color_mapping[edge_class])
126
+ mask_raw = cv2.inRange(gest_seg_np, edge_color-0.5, edge_color+0.5)
127
+ # Possibly do morphological open/close to avoid merges or small holes
128
+ kernel = np.ones((5, 5), np.uint8) # smaller kernel to reduce over-merge
129
+ mask = cv2.morphologyEx(mask_raw, cv2.MORPH_CLOSE, kernel)
130
+ if mask.sum() == 0:
131
+ continue
132
+
133
+ # Connected components
134
+ output = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S)
135
+ (numLabels, labels, stats, centroids) = output
136
+ # skip the background
137
+ stats, centroids = stats[1:], centroids[1:]
138
+ label_indices = range(1, numLabels)
139
+
140
+ # For each connected component, do a line fit
141
+ for lbl in label_indices:
142
+ ys, xs = np.where(labels == lbl)
143
+ if len(xs) < 2:
144
+ continue
145
+ # Fit a line using cv2.fitLine
146
+ pts_for_fit = np.column_stack([xs, ys]).astype(np.float32)
147
+ # (vx, vy, x0, y0) = direction + a point on the line
148
+ line_params = cv2.fitLine(pts_for_fit, distType=cv2.DIST_L2,
149
+ param=0, reps=0.01, aeps=0.01)
150
+ vx, vy, x0, y0 = line_params.ravel()
151
+ # We'll approximate endpoints by projecting (xs, ys) onto the line,
152
+ # then taking min and max in the 1D param along the line.
153
+
154
+ # param along the line = ( (x - x0)*vx + (y - y0)*vy )
155
+ proj = ( (xs - x0)*vx + (ys - y0)*vy )
156
+ proj_min, proj_max = proj.min(), proj.max()
157
+ p1 = np.array([x0 + proj_min*vx, y0 + proj_min*vy])
158
+ p2 = np.array([x0 + proj_max*vx, y0 + proj_max*vy])
159
+
160
+ #--------------------------------------------------------------------------------
161
+ # Step C: If apex points are within 'edge_th' of segment, they are connected
162
+ #--------------------------------------------------------------------------------
163
+ if len(apex_pts) < 2:
164
+ continue
165
+
166
+ # Distance from each apex to the line segment
167
+ dists = np.array([
168
+ point_to_segment_dist(apex_pts[i], p1, p2)
169
+ for i in range(len(apex_pts))
170
+ ])
171
+
172
+ # Indices of apex points that are near
173
+ near_mask = (dists <= edge_th)
174
+ near_indices = np.where(near_mask)[0]
175
+ if len(near_indices) < 2:
176
+ continue
177
+
178
+ # Connect each pair among these near apex points
179
+ for i in range(len(near_indices)):
180
+ for j in range(i+1, len(near_indices)):
181
+ a_idx = near_indices[i]
182
+ b_idx = near_indices[j]
183
+ # 'a_idx' and 'b_idx' are indices in apex_pts / apex_idx_map
184
+ vA = apex_idx_map[a_idx]
185
+ vB = apex_idx_map[b_idx]
186
+ # Store the connection using sorted indexing
187
+ conn = tuple(sorted((vA, vB)))
188
+ connections.append(conn)
189
+
190
+ return vertices, connections
191
+
192
+
193
+ def get_uv_depth(vertices: List[dict],
194
+ depth_fitted: np.ndarray,
195
+ sparse_depth: np.ndarray,
196
+ search_radius: int = 10) -> Tuple[np.ndarray, np.ndarray]:
197
+ """
198
+ For each vertex, returns a 2D array of (u,v) and a matching 1D array of depths.
199
+
200
+ We attempt to use the sparse_depth if available in a local neighborhood:
201
+ 1. For each vertex coordinate (x, y), define a local window in sparse_depth
202
+ of size (2*search_radius + 1).
203
+ 2. Collect all valid (nonzero) values in that window.
204
+ 3. If any exist, we take the *closest* valid pixel's depth.
205
+ 4. Otherwise, we use depth_fitted[y, x].
206
+
207
+ Parameters
208
+ ----------
209
+ vertices : List[dict]
210
+ Each dict must have "xy" at least, e.g. {"xy": (x, y), ...}
211
+ depth_fitted : np.ndarray
212
+ A 2D array (H, W), the dense (or corrected) depth for fallback.
213
+ sparse_depth : np.ndarray
214
+ A 2D array (H, W), mostly zeros except where accurate data is available.
215
+ search_radius : int
216
+ Pixel radius around the vertex in which to look for sparse depth values.
217
+
218
+ Returns
219
+ -------
220
+ uv : np.ndarray of shape (N, 2)
221
+ 2D float coordinates of each vertex (x, y).
222
+ vertex_depth : np.ndarray of shape (N,)
223
+ Depth value chosen for each vertex.
224
+ """
225
+
226
+ # Collect each vertex's (x, y)
227
+ uv = np.array([vert['xy'] for vert in vertices], dtype=np.float32)
228
+
229
+ # Convert to integer pixel coordinates (round or floor)
230
+ uv_int = np.round(uv).astype(np.int32)
231
+ H, W = depth_fitted.shape[:2]
232
+
233
+ # Clip coordinates to stay within image bounds
234
+ uv_int[:, 0] = np.clip(uv_int[:, 0], 0, W - 1)
235
+ uv_int[:, 1] = np.clip(uv_int[:, 1], 0, H - 1)
236
+
237
+ # Prepare output array of depths
238
+ vertex_depth = np.zeros(len(vertices), dtype=np.float32)
239
+ dense_count = 0
240
+
241
+ for i, (x_i, y_i) in enumerate(uv_int):
242
+ # Local region in [x_i - search_radius, x_i + search_radius]
243
+ x0 = max(0, x_i - search_radius)
244
+ x1 = min(W, x_i + search_radius + 1)
245
+ y0 = max(0, y_i - search_radius)
246
+ y1 = min(H, y_i + search_radius + 1)
247
+
248
+ # Crop out the local window in sparse_depth
249
+ region = sparse_depth[y0:y1, x0:x1]
250
+
251
+ # Find all valid (non-zero) depths
252
+ valid_mask = (region > 0)
253
+ valid_y, valid_x = np.where(valid_mask)
254
+
255
+ if valid_y.size > 0:
256
+ # Compute global coordinates for each valid pixel
257
+ global_x = x0 + valid_x
258
+ global_y = y0 + valid_y
259
+
260
+ # Compute squared distance to center (x_i, y_i)
261
+ dist_sq = (global_x - x_i)**2 + (global_y - y_i)**2
262
+
263
+ # Find the nearest valid pixel
264
+ min_idx = np.argmin(dist_sq)
265
+ nearest_depth = region[valid_y[min_idx], valid_x[min_idx]]
266
+ vertex_depth[i] = nearest_depth
267
+ else:
268
+ # Fallback to the dense depth
269
+ vertex_depth[i] = depth_fitted[y_i, x_i]
270
+ dense_count += 1
271
+ return uv, vertex_depth
272
+
273
+
274
+
275
+ def project_vertices_to_3d(uv: np.ndarray, depth_vert: np.ndarray, col_img: pycolmap.Image) -> np.ndarray:
276
+ """
277
+ Projects 2D vertex coordinates with associated depths to 3D world coordinates.
278
+
279
+ Parameters
280
+ ----------
281
+ uv : np.ndarray
282
+ (N, 2) array of 2D vertex coordinates (u, v).
283
+ depth_vert : np.ndarray
284
+ (N,) array of depth values for each vertex.
285
+ col_img : pycolmap.Image
286
+
287
+ Returns
288
+ -------
289
+ vertices_3d : np.ndarray
290
+ (N, 3) array of vertex coordinates in 3D world space.
291
+ """
292
+ # Backproject to 3D local camera coordinates
293
+ xy_local = np.ones((len(uv), 3))
294
+ K = col_img.camera.calibration_matrix()
295
+ xy_local[:, 0] = (uv[:, 0] - K[0, 2]) / K[0, 0]
296
+ xy_local[:, 1] = (uv[:, 1] - K[1, 2]) / K[1, 1]
297
+ # Get the 3D vertices
298
+ vertices_3d_local = xy_local * depth_vert[...,None]
299
+
300
+ # Create camera-to-world transformation matrix
301
+ world_to_cam = np.eye(4)
302
+ world_to_cam[:3] = col_img.cam_from_world.matrix()
303
+ cam_to_world = np.linalg.inv(world_to_cam)
304
+
305
+ # Transform local 3D points to world coordinates
306
+ vertices_3d_homogeneous = cv2.convertPointsToHomogeneous(vertices_3d_local)
307
+ vertices_3d = cv2.transform(vertices_3d_homogeneous, cam_to_world)
308
+ vertices_3d = cv2.convertPointsFromHomogeneous(vertices_3d).reshape(-1, 3)
309
+ return vertices_3d
310
+
311
+
312
+ def create_3d_wireframe_single_image(vertices: List[dict],
313
+ connections: List[Tuple[int, int]],
314
+ depth: PImage,
315
+ colmap_rec: pycolmap.Reconstruction,
316
+ img_id: str,
317
+ ade_seg: PImage) -> np.ndarray:
318
+ """
319
+ Processes a single image view to generate 3D vertex coordinates from existing 2D vertices/edges.
320
+
321
+ Parameters
322
+ ----------
323
+ vertices : List[dict]
324
+ List of 2D vertex dictionaries (e.g., {"xy": (x, y), "type": ...}).
325
+ connections : List[Tuple[int, int]]
326
+ List of 2D edge connections (indices into the vertices list).
327
+ depth : PIL.Image
328
+ Initial dense depth map as a PIL Image.
329
+ colmap_rec : pycolmap.Reconstruction
330
+ COLMAP reconstruction data.
331
+ img_id : str
332
+ Identifier for the current image within the COLMAP reconstruction.
333
+ ade_seg : PIL.Image
334
+ ADE20k segmentation map for the image.
335
+
336
+ Returns
337
+ -------
338
+ vertices_3d : np.ndarray
339
+ (N, 3) array of vertex coordinates in 3D world space.
340
+ Returns an empty array if processing fails (e.g., missing sparse depth).
341
+ """
342
+ # Check if initial vertices/connections are valid
343
+ if (len(vertices) < 2) or (len(connections) < 1):
344
+ # This case should ideally be handled before calling, but good to double check.
345
+ print(f'Warning: create_3d_wireframe_single_image called with insufficient vertices/connections for image {img_id}')
346
+ return np.empty((0, 3))
347
+
348
+ # Get fitted dense depth and sparse depth
349
+ depth_fitted, depth_sparse, found_sparse, col_img = get_fitted_dense_depth(
350
+ depth, colmap_rec, img_id, ade_seg
351
+ )
352
+
353
+ # Get UV coordinates and depth for each vertex
354
+ uv, depth_vert = get_uv_depth(vertices, depth_fitted, depth_sparse, 10)
355
+
356
+ # Backproject to 3D
357
+ vertices_3d = project_vertices_to_3d(uv, depth_vert, col_img)
358
+
359
+ return vertices_3d
360
+
361
+
362
+ def merge_vertices_3d(vert_edge_per_image, th=0.5):
363
+ '''Merge vertices that are close to each other in 3D space and are of same types'''
364
+ # Initialize structures to collect vertices and connections from all images
365
+ all_3d_vertices = []
366
+ connections_3d = []
367
+ all_indexes = []
368
+ cur_start = 0
369
+ types = []
370
+
371
+ # Combine vertices and update connection indices across all images
372
+ for cimg_idx, (vertices, connections, vertices_3d) in vert_edge_per_image.items():
373
+ types += [int(v['type']=='apex') for v in vertices]
374
+ all_3d_vertices.append(vertices_3d)
375
+ connections_3d+=[(x+cur_start,y+cur_start) for (x,y) in connections]
376
+ cur_start+=len(vertices_3d)
377
+ all_3d_vertices = np.concatenate(all_3d_vertices, axis=0)
378
+
379
+ # Calculate distance matrix between all vertices
380
+ distmat = cdist(all_3d_vertices, all_3d_vertices)
381
+ types = np.array(types).reshape(-1,1)
382
+ same_types = cdist(types, types)
383
+
384
+ # Create mask for vertices that should be merged (close in space and same type)
385
+ mask_to_merge = (distmat <= th) & (same_types==0)
386
+ new_vertices = []
387
+ new_connections = []
388
+
389
+ # Extract vertex indices to merge based on the mask
390
+ to_merge = sorted(list(set([tuple(a.nonzero()[0].tolist()) for a in mask_to_merge])))
391
+
392
+ # Build groups of vertices to merge (transitive grouping)
393
+ to_merge_final = defaultdict(list)
394
+ for i in range(len(all_3d_vertices)):
395
+ for j in to_merge:
396
+ if i in j:
397
+ to_merge_final[i]+=j
398
+
399
+ # Remove duplicates in each group
400
+ for k, v in to_merge_final.items():
401
+ to_merge_final[k] = list(set(v))
402
+
403
+ # Create final merge groups without duplicates
404
+ already_there = set()
405
+ merged = []
406
+ for k, v in to_merge_final.items():
407
+ if k in already_there:
408
+ continue
409
+ merged.append(v)
410
+ for vv in v:
411
+ already_there.add(vv)
412
+
413
+ # Calculate new vertex positions (average of merged groups)
414
+ old_idx_to_new = {}
415
+ count=0
416
+ for idxs in merged:
417
+ new_vertices.append(all_3d_vertices[idxs].mean(axis=0))
418
+ for idx in idxs:
419
+ old_idx_to_new[idx] = count
420
+ count +=1
421
+ new_vertices=np.array(new_vertices)
422
+
423
+ # Update connections to use new vertex indices
424
+ for conn in connections_3d:
425
+ new_con = sorted((old_idx_to_new[conn[0]], old_idx_to_new[conn[1]]))
426
+ if new_con[0] == new_con[1]:
427
+ continue
428
+ if new_con not in new_connections:
429
+ new_connections.append(new_con)
430
+ return new_vertices, new_connections
431
+
432
+
433
+ def prune_not_connected(all_3d_vertices, connections_3d, keep_largest=True):
434
+ """
435
+ Prune vertices not connected to anything. If keep_largest=True, also
436
+ keep only the largest connected component in the graph.
437
+ """
438
+ if len(all_3d_vertices) == 0:
439
+ return np.array([]), []
440
+
441
+ # adjacency
442
+ adj = defaultdict(set)
443
+ for (i, j) in connections_3d:
444
+ adj[i].add(j)
445
+ adj[j].add(i)
446
+
447
+ # keep only vertices that appear in at least one edge
448
+ used_idxs = set()
449
+ for (i, j) in connections_3d:
450
+ used_idxs.add(i)
451
+ used_idxs.add(j)
452
+
453
+ if not used_idxs:
454
+ return np.empty((0,3)), []
455
+
456
+ # If we only want to remove truly isolated points, but keep multiple subgraphs:
457
+ if not keep_largest:
458
+ new_map = {}
459
+ used_list = sorted(list(used_idxs))
460
+ for new_id, old_id in enumerate(used_list):
461
+ new_map[old_id] = new_id
462
+ new_vertices = np.array([all_3d_vertices[old_id] for old_id in used_list])
463
+ new_conns = []
464
+ for (i, j) in connections_3d:
465
+ if i in used_idxs and j in used_idxs:
466
+ new_conns.append((new_map[i], new_map[j]))
467
+ return new_vertices, new_conns
468
+
469
+ # Otherwise find the largest connected component:
470
+ visited = set()
471
+ def bfs(start):
472
+ queue = [start]
473
+ comp = []
474
+ visited.add(start)
475
+ while queue:
476
+ cur = queue.pop()
477
+ comp.append(cur)
478
+ for neigh in adj[cur]:
479
+ if neigh not in visited:
480
+ visited.add(neigh)
481
+ queue.append(neigh)
482
+ return comp
483
+
484
+ # Collect all subgraphs
485
+ comps = []
486
+ for idx in used_idxs:
487
+ if idx not in visited:
488
+ c = bfs(idx)
489
+ comps.append(c)
490
+
491
+ # pick largest
492
+ comps.sort(key=lambda c: len(c), reverse=True)
493
+ largest = comps[0] if len(comps)>0 else []
494
+
495
+ # Remap
496
+ new_map = {}
497
+ for new_id, old_id in enumerate(largest):
498
+ new_map[old_id] = new_id
499
+
500
+ new_vertices = np.array([all_3d_vertices[old_id] for old_id in largest])
501
+ new_conns = []
502
+ for (i, j) in connections_3d:
503
+ if i in largest and j in largest:
504
+ new_conns.append((new_map[i], new_map[j]))
505
+
506
+ # remove duplicates
507
+ new_conns = list(set([tuple(sorted(c)) for c in new_conns]))
508
+ return new_vertices, new_conns
509
+
510
+ def get_sparse_depth(colmap_rec, img_id_substring, depth):
511
+ """
512
+ Return a sparse depth map for the COLMAP image whose name contains
513
+ `img_id_substring`. The output is an array of shape `depth_shape` (H,W),
514
+ where only the projected 3D points get a depth > 0, else 0.
515
+ """
516
+ H, W = depth.shape
517
+
518
+ # 1) Find the matching COLMAP image
519
+ found_img = None
520
+ for img_id_c, col_img in colmap_rec.images.items():
521
+ if img_id_substring in col_img.name:
522
+ found_img = col_img
523
+ break
524
+ if found_img is None:
525
+ print(f"Image substring {img_id_substring} not found in COLMAP.")
526
+ return np.zeros((H, W), dtype=np.float32), False, None
527
+
528
+ # 2) Gather 3D points that this image sees
529
+ points_xyz = []
530
+ for pid, p3D in colmap_rec.points3D.items():
531
+ if found_img.has_point3D(pid):
532
+ points_xyz.append(p3D.xyz) # world coords
533
+ if not points_xyz:
534
+ print(f"No 3D points associated with {found_img.name}.")
535
+ return np.zeros((H, W), dtype=np.float32), False, found_img
536
+
537
+ points_xyz = np.array(points_xyz) # (N, 3)
538
+
539
+ # 3) For each point, project via col_img.project_point()
540
+ uv = []
541
+ z_vals = []
542
+ for xyz in points_xyz:
543
+ proj = found_img.project_point(xyz) # returns (u, v) in image coords or None
544
+ if proj is not None:
545
+ u_i, v_i = proj
546
+ u_i = int(round(u_i))
547
+ v_i = int(round(v_i))
548
+ # Check in-bounds
549
+ if 0 <= u_i < W and 0 <= v_i < H:
550
+ uv.append((u_i, v_i))
551
+ # We'll compute depth as Z in camera coords
552
+ # from the world->cam transform col_img holds
553
+ mat4x4 = np.eye(4)
554
+ mat4x4[:3, :4] = found_img.cam_from_world.matrix()
555
+ p_cam = mat4x4@ np.array([xyz[0], xyz[1], xyz[2], 1.0])
556
+ z_vals.append(p_cam[2] / p_cam[3])
557
+
558
+ uv = np.array(uv, dtype=int) # shape (M,2)
559
+ z_vals = np.array(z_vals) # shape (M,)
560
+
561
+ depth_out = np.zeros((H, W), dtype=np.float32)
562
+ depth_out[uv[:,1], uv[:,0]] = z_vals # Note: uv = (u, v), so row = v, col = u
563
+
564
+ return depth_out, True, found_img
565
+
566
+
567
+ def fit_scale_robust_median(depth, sparse_depth, validity_mask=None):
568
+ """
569
+ Fit a scale factor to the depth map using the median of the ratio of sparse to dense depth.
570
+ """
571
+ if validity_mask is None:
572
+ mask = (sparse_depth != 0)
573
+ else:
574
+ mask = (sparse_depth != 0) & validity_mask
575
+ mask = mask & (depth <50) & (sparse_depth <50)
576
+ X = depth[mask]
577
+ Y = sparse_depth[mask]
578
+ alpha =np.median(Y/X)
579
+ depth_fitted = alpha * depth
580
+ return alpha, depth_fitted
581
+
582
+
583
+ def get_fitted_dense_depth(depth, colmap_rec, img_id, ade20k_seg):
584
+ """
585
+ Gets sparse depth from COLMAP, computes a house mask, fits dense depth to sparse
586
+ depth within the mask, and returns the fitted dense depth.
587
+
588
+ Parameters
589
+ ----------
590
+ depth : np.ndarray
591
+ Initial dense depth map (H, W).
592
+ colmap_rec : pycolmap.Reconstruction
593
+ COLMAP reconstruction data.
594
+ img_id : str
595
+ Identifier for the current image within the COLMAP reconstruction.
596
+ K : np.ndarray
597
+ Camera intrinsic matrix (3x3).
598
+ R : np.ndarray
599
+ Camera rotation matrix (3x3).
600
+ t : np.ndarray
601
+ Camera translation vector (3,).
602
+ ade20k_seg : PIL.Image
603
+ ADE20k segmentation map for the image.
604
+
605
+ Returns
606
+ -------
607
+ depth_fitted : np.ndarray
608
+ Dense depth map scaled and shifted to align with sparse depth within the house mask (H, W).
609
+ depth_sparse : np.ndarray
610
+ The sparse depth map obtained from COLMAP (H, W).
611
+ found_sparse : bool
612
+ True if sparse depth points were found for this image, False otherwise.
613
+ """
614
+ depth_np = np.array(depth) / 1000. # Convert mm to meters if needed
615
+ depth_sparse, found_sparse, col_img = get_sparse_depth(colmap_rec, img_id, depth_np)
616
+
617
+ if not found_sparse:
618
+ print(f'No sparse depth found for image {img_id}')
619
+ # Return original (meter-scaled) depth if no sparse data
620
+ return depth_np, np.zeros_like(depth_np), False, None
621
+
622
+ # Get house mask to focus fitting on relevant areas
623
+ house_mask = get_house_mask(ade20k_seg)
624
+
625
+ # Fit dense depth to sparse depth (scale only), using only points within the house mask
626
+ k, depth_fitted = fit_scale_robust_median(depth_np, depth_sparse, validity_mask=house_mask)
627
+ print(f"Fitted depth scale k={k:.4f} for image {img_id}")
628
+ #depth_fitted = depth_np# * house_mask.astype(np.float32)
629
+ depth_sparse = depth_sparse# * house_mask.astype(np.float32)
630
+ return depth_fitted, depth_sparse, True, col_img
631
+
632
+
633
+ def prune_too_far(all_3d_vertices, connections_3d, colmap_rec, th = 3.0):
634
+ """
635
+ Prune vertices that are too far from sparse point cloud
636
+
637
+ """
638
+ xyz_sfm=[]
639
+ for k, v in colmap_rec.points3D.items():
640
+ xyz_sfm.append(v.xyz)
641
+ xyz_sfm = np.array(xyz_sfm)
642
+ distmat = cdist(all_3d_vertices, xyz_sfm)
643
+ mindist = distmat.min(axis=1)
644
+ mask = mindist <= th
645
+ all_3d_vertices_new = all_3d_vertices[mask]
646
+ old_idx_survived = np.arange(len(all_3d_vertices))[mask]
647
+ new_idxs = np.arange(len(all_3d_vertices_new))
648
+ old_to_new_idx = dict(zip(old_idx_survived, new_idxs))
649
+ connections_3d_new = [(old_to_new_idx[conn[0]], old_to_new_idx[conn[1]]) for conn in connections_3d if mask[conn[0]] and mask[conn[1]]]
650
+ return all_3d_vertices_new, connections_3d_new
651
+
652
+
653
+ def predict_wireframe(entry) -> Tuple[np.ndarray, List[int]]:
654
+ """
655
+ Predict 3D wireframe from a dataset entry.
656
+ """
657
+ good_entry = convert_entry_to_human_readable(entry)
658
+ vert_edge_per_image = {}
659
+ for i, (gest, depth, K, R, t, img_id, ade_seg) in enumerate(zip(good_entry['gestalt'],
660
+ good_entry['depth'],
661
+ good_entry['K'],
662
+ good_entry['R'],
663
+ good_entry['t'],
664
+ good_entry['image_ids'],
665
+ good_entry['ade'] # Added ade20k segmentation
666
+ )):
667
+ colmap_rec = good_entry['colmap_binary']
668
+ K = np.array(K)
669
+ R = np.array(R)
670
+ t = np.array(t)
671
+ # Resize gestalt segmentation to match depth map size
672
+ depth_size = (np.array(depth).shape[1], np.array(depth).shape[0]) # W, H
673
+ gest_seg = gest.resize(depth_size)
674
+ gest_seg_np = np.array(gest_seg).astype(np.uint8)
675
+
676
+ # Get 2D vertices and edges first
677
+ vertices, connections = get_vertices_and_edges_from_segmentation(gest_seg_np, edge_th=10.)
678
+
679
+ # Check if we have enough to proceed
680
+ if (len(vertices) < 2) or (len(connections) < 1):
681
+ print(f'Not enough vertices or connections found in image {i}, skipping.')
682
+ vert_edge_per_image[i] = [], [], np.empty((0, 3))
683
+ continue
684
+
685
+ # Call the refactored function to get 3D points
686
+ vertices_3d = create_3d_wireframe_single_image(
687
+ vertices, connections, depth, colmap_rec, img_id, ade_seg
688
+ )
689
+ # Store original 2D vertices, connections, and computed 3D points
690
+ vert_edge_per_image[i] = vertices, connections, vertices_3d
691
+
692
+ # Merge vertices from all images
693
+ all_3d_vertices, connections_3d = merge_vertices_3d(vert_edge_per_image, 0.5)
694
+ all_3d_vertices_clean, connections_3d_clean = prune_not_connected(all_3d_vertices, connections_3d, keep_largest=False)
695
+ all_3d_vertices_clean, connections_3d_clean = prune_too_far(all_3d_vertices_clean, connections_3d_clean, colmap_rec, th = 4.0)
696
+
697
+ if (len(all_3d_vertices_clean) < 2) or len(connections_3d_clean) < 1:
698
+ print (f'Not enough vertices or connections in the 3D vertices')
699
+ return empty_solution()
700
+
701
+ return all_3d_vertices_clean, connections_3d_clean
{hoho → hoho2025}/hoho.py RENAMED
@@ -13,6 +13,7 @@ import numpy as np
13
  import importlib
14
  import subprocess
15
 
 
16
  from PIL import ImageFile
17
 
18
  from huggingface_hub.utils._headers import build_hf_headers # note: using _headers
@@ -184,8 +185,9 @@ def proc(row, split='train'):
184
  return Sample(out)
185
 
186
 
187
- from . import read_write_colmap
188
  def decode_colmap(s):
 
189
  with temp_working_directory():
190
 
191
  with open('points3D.bin', 'wb') as stream:
 
13
  import importlib
14
  import subprocess
15
 
16
+
17
  from PIL import ImageFile
18
 
19
  from huggingface_hub.utils._headers import build_hf_headers # note: using _headers
 
185
  return Sample(out)
186
 
187
 
188
+
189
  def decode_colmap(s):
190
+ import hoho2025.read_write_colmap as read_write_colmap
191
  with temp_working_directory():
192
 
193
  with open('points3D.bin', 'wb') as stream:
hoho2025/metric_helper.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from scipy.spatial.distance import cdist
3
+ from scipy.optimize import linear_sum_assignment
4
+ import torch
5
+ import trimesh
6
+ from time import time
7
+
8
+ MAX_SCORE = 1.0
9
+
10
+ def get_one_primitive(p1, p2, c=(255, 0, 0), radius=25, primitive_type='cylinder', sections=6):
11
+ if len(c) == 1:
12
+ c = [c[0]] * 4
13
+ elif len(c) == 3:
14
+ c = [*c, 255]
15
+ elif len(c) != 4:
16
+ raise ValueError(f'{c} is not a valid color (must have 1,3, or 4 elements).')
17
+
18
+ p1, p2 = np.asarray(p1), np.asarray(p2)
19
+ l = np.linalg.norm(p2 - p1)
20
+
21
+ # Add check for zero-length edges
22
+ if l < 1e-6:
23
+ return None
24
+
25
+ direction = (p2 - p1) / l
26
+
27
+ T = np.eye(4)
28
+ T[:3, 2] = direction
29
+ T[:3, 3] = (p1 + p2) / 2
30
+
31
+ b0, b1 = T[:3, 0], T[:3, 1]
32
+ if np.abs(np.dot(b0, direction)) < np.abs(np.dot(b1, direction)):
33
+ T[:3, 1] = -np.cross(b0, direction)
34
+ else:
35
+ T[:3, 0] = np.cross(b1, direction)
36
+
37
+ if primitive_type == 'capsule':
38
+ mesh = trimesh.primitives.Capsule(radius=radius, height=l, transform=T, sections=sections)
39
+ elif primitive_type == 'cylinder':
40
+ mesh = trimesh.primitives.Cylinder(radius=radius, height=l, transform=T, sections=sections)
41
+ else:
42
+ raise ValueError("Unknown primitive!")
43
+
44
+ # Add vertex color initialization check
45
+ if not hasattr(mesh.visual, 'vertex_colors') or mesh.visual.vertex_colors is None:
46
+ mesh.visual.vertex_colors = np.ones((len(mesh.vertices), 4)) * 255
47
+
48
+ mesh.visual.vertex_colors = np.ones_like(mesh.visual.vertex_colors) * c
49
+ return mesh
50
+
51
+ def get_primitives(vertices, edges, radius=25, c=[255, 0, 0]):
52
+ # Convert vertices to a NumPy array
53
+ if isinstance(vertices, torch.Tensor):
54
+ vertices = vertices.detach().cpu().numpy()
55
+ else:
56
+ vertices = np.asarray(vertices)
57
+
58
+ # Convert edges to a NumPy array of integers
59
+ if isinstance(edges, torch.Tensor):
60
+ edges = edges.detach().cpu().numpy().astype(np.int64)
61
+ else:
62
+ edges = np.asarray(edges, dtype=np.int64)
63
+
64
+ primitives = []
65
+ for e in edges:
66
+ # Add edge validation
67
+ if e[0] >= len(vertices) or e[1] >= len(vertices):
68
+ continue
69
+ primitive = get_one_primitive(vertices[e[0]], vertices[e[1]], radius=radius, c=c)
70
+ if primitive is not None:
71
+ primitives.append(primitive)
72
+ return primitives
73
+
74
+
75
+
76
+ def compute_mesh_iou_VOLUME(pd_vertices, pd_edges, gt_vertices, gt_edges, radius=20, engine='manifold'):
77
+ # check empty
78
+ if len(pd_edges) == 0 or len(gt_edges) == 0:
79
+ return 0.0
80
+
81
+ pd_vertices = pd_vertices.detach().cpu() if isinstance(pd_vertices, torch.Tensor) else pd_vertices
82
+ pd_edges = pd_edges.detach().cpu() if isinstance(pd_edges, torch.Tensor) else pd_edges
83
+ gt_vertices = gt_vertices.detach().cpu() if isinstance(gt_vertices, torch.Tensor) else gt_vertices
84
+ gt_edges = gt_edges.detach().cpu() if isinstance(gt_edges, torch.Tensor) else gt_edges
85
+
86
+ pd_primitives = get_primitives(pd_vertices, pd_edges, radius=radius, c=[0, 255, 0])
87
+ gt_primitives = get_primitives(gt_vertices, gt_edges, radius=radius, c=[255, 0, 0])
88
+ # check for empty primitives
89
+ if not pd_primitives or not gt_primitives:
90
+ return 0.0
91
+
92
+ # Add bounding box check to detect non-overlapping cases quickly
93
+ pd_bounds = np.array([p.bounds for p in pd_primitives])
94
+ gt_bounds = np.array([p.bounds for p in gt_primitives])
95
+
96
+ pd_min, pd_max = np.min(pd_bounds[:, 0], axis=0), np.max(pd_bounds[:, 1], axis=0)
97
+ gt_min, gt_max = np.min(gt_bounds[:, 0], axis=0), np.max(gt_bounds[:, 1], axis=0)
98
+
99
+ # If bounding boxes don't overlap, return 0
100
+ if np.any(pd_max < gt_min) or np.any(pd_min > gt_max):
101
+ return 0.0
102
+ t=time()
103
+ mesh_pred = trimesh.boolean.union(pd_primitives, engine=engine)
104
+ #print(f"mesh_pred union: {time() - t} {mesh_pred.is_volume}")
105
+ t=time()
106
+ mesh_gt= trimesh.boolean.union(gt_primitives, engine=engine)
107
+ #print(f"mesh_gt union: {time() - t} {mesh_gt.is_volume}")
108
+
109
+ if mesh_pred.is_volume and mesh_gt.is_volume:
110
+ t=time()
111
+ inter_volume = trimesh.boolean.intersection([mesh_pred, mesh_gt], engine=engine).volume
112
+ #print(f"inter_volume: {time() - t}")
113
+ else:
114
+ all_inter = []
115
+ t=time()
116
+ for pd_prim in pd_primitives:
117
+ pd_min, pd_max = pd_prim.bounds
118
+ for gt_prim in gt_primitives:
119
+ # Skip intersection calculation if bounding boxes don't overlap
120
+ gt_min, gt_max = gt_prim.bounds
121
+ if np.any(pd_max < gt_min) or np.any(pd_min > gt_max):
122
+ continue
123
+ inter = trimesh.boolean.intersection([pd_prim, gt_prim], engine=engine)
124
+ if inter.is_volume and inter.volume > 0:
125
+ all_inter.append(inter)
126
+ inter_volume = trimesh.boolean.union(all_inter, engine=engine).volume if all_inter else 0
127
+ #print(f"all_inter: {time() - t}")
128
+ union_volume = mesh_pred.volume + mesh_gt.volume - inter_volume
129
+
130
+ return inter_volume / union_volume if union_volume > 0 else 0.0
131
+
132
+
133
+ # ----------------- Corner F1 -----------------
134
+ def compute_ap_metrics(pd_vertices, gt_vertices, thresh=25):
135
+ if len(pd_vertices) == 0 or len(gt_vertices) == 0:
136
+ return 0.0
137
+
138
+ dists = cdist(pd_vertices, gt_vertices)
139
+ row_ind, col_ind = linear_sum_assignment(dists)
140
+
141
+ tp = (dists[row_ind, col_ind] <= thresh).sum()
142
+ precision = tp / len(pd_vertices) if len(pd_vertices) > 0 else 0
143
+ recall = tp / len(gt_vertices) if len(gt_vertices) > 0 else 0
144
+ denom = precision + recall
145
+ f1 = (2 * precision * recall / denom) if denom > 0 else 0.0
146
+ return f1
147
+
148
+ def batch_corner_f1(X, Y, distance_thresh=25):
149
+ results = []
150
+ for (pd_v, _), (gt_v, _) in zip(X, Y):
151
+ results.append(compute_ap_metrics(pd_v, gt_v, thresh=distance_thresh))
152
+ return np.array(results)
153
+
154
+ # ----------------- HSS Metric -----------------
155
+ from collections import namedtuple
156
+ HSSReturnType = namedtuple('HSSReturnType', ['hss', 'f1', 'iou'])
157
+ def hss(y_hat_v, y_hat_e, y_v, y_e, vert_thresh=0.5, edge_thresh=0.5):
158
+ X = [(y_hat_v, y_hat_e)]
159
+ Y = [(y_v, y_e)]
160
+ t=time()
161
+ f1 = np.clip(batch_corner_f1(X, Y, distance_thresh=vert_thresh)[0], 0, 1)
162
+ #print(f"f1 {f1}: in {time() - t:.2f} sec")
163
+ t=time()
164
+ IoU = np.clip(compute_mesh_iou_VOLUME(y_hat_v, y_hat_e, y_v, y_e, radius=edge_thresh), 0, 1)
165
+ #print(f"IoU: {IoU} in {time() - t:.2f} sec")
166
+ score = 2 * f1 * IoU / (f1 + IoU) if (f1 + IoU) > 0 else 0.0
167
+ return HSSReturnType(hss=score, f1=f1, iou=IoU)
{hoho → hoho2025}/read_write_colmap.py RENAMED
@@ -486,4 +486,3 @@ def rotmat2qvec(R):
486
  if qvec[0] < 0:
487
  qvec *= -1
488
  return qvec
489
-
 
486
  if qvec[0] < 0:
487
  qvec *= -1
488
  return qvec
 
{hoho → hoho2025}/vis.py RENAMED
@@ -1,3 +1,5 @@
 
 
1
  import trimesh
2
  import numpy as np
3
  from copy import deepcopy
@@ -5,6 +7,39 @@ from PIL import Image
5
 
6
  from . import color_mappings
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def line(p1, p2, c=(255,0,0), resolution=10, radius=0.05):
9
  '''draws a 3d cylinder along the line (p1, p2)'''
10
  # check colors
@@ -114,8 +149,6 @@ def show_grid(edges, meshes=None, row_length=5):
114
  return trimesh.Scene(out)
115
 
116
 
117
-
118
-
119
  def visualize_order_images(row_order):
120
  return create_image_grid(row_order['ade20k'] + row_order['gestalt'] + [visualize_depth(dm) for dm in row_order['depthcm']], num_per_row=len(row_order['ade20k']))
121
 
@@ -146,8 +179,6 @@ def create_image_grid(images, target_length=312, num_per_row=2):
146
  return grid_img
147
 
148
 
149
- import matplotlib.pyplot as plt
150
-
151
  def visualize_depth(depth, min_depth=None, max_depth=None, cmap='rainbow'):
152
  depth = np.array(depth)
153
 
 
1
+
2
+ import matplotlib.pyplot as plt
3
  import trimesh
4
  import numpy as np
5
  from copy import deepcopy
 
7
 
8
  from . import color_mappings
9
 
10
+
11
+ def plot_all_modalities(ds_entry, figsize=(8, 15)):
12
+ modalities_to_plot = ['images', 'depth', 'gestalt', 'ade']
13
+ modalities_in_entry = [k for k in ds_entry.keys() if k in modalities_to_plot and len(ds_entry[k]) > 0]
14
+ number_of_columns = len(modalities_in_entry)
15
+ number_of_images = len(ds_entry['image_ids'])
16
+ number_of_rows = number_of_images
17
+ fig, axes = plt.subplots(number_of_rows, number_of_columns, figsize=figsize)
18
+ for i in range(len(ds_entry[modalities_in_entry[0]])):
19
+ for j, modality in enumerate(modalities_in_entry):
20
+ ax = axes[i, j]
21
+ if modality == 'image':
22
+ ax.imshow(ds_entry[modality][i])
23
+ elif modality == 'depth':
24
+ depth_image = np.array(ds_entry[modality][i])/1000.0
25
+ ax.imshow(depth_image, cmap='rainbow')
26
+ elif modality == 'gestalt':
27
+ ax.imshow(ds_entry[modality][i])
28
+ elif modality == 'ade':
29
+ ax.imshow(ds_entry[modality][i])
30
+ else:
31
+ raise ValueError(f"Unknown modality: {modality}")
32
+ if i == 0:
33
+ ax.set_title(modality)
34
+ ax.axis('off')
35
+ if j == 0:
36
+ ax.set_ylabel(f"Image {i}")
37
+ fig.tight_layout()
38
+ fig.subplots_adjust(wspace=0.05, hspace=0.01)
39
+ #plt.show()
40
+ return fig, axes
41
+
42
+
43
  def line(p1, p2, c=(255,0,0), resolution=10, radius=0.05):
44
  '''draws a 3d cylinder along the line (p1, p2)'''
45
  # check colors
 
149
  return trimesh.Scene(out)
150
 
151
 
 
 
152
  def visualize_order_images(row_order):
153
  return create_image_grid(row_order['ade20k'] + row_order['gestalt'] + [visualize_depth(dm) for dm in row_order['depthcm']], num_per_row=len(row_order['ade20k']))
154
 
 
179
  return grid_img
180
 
181
 
 
 
182
  def visualize_depth(depth, min_depth=None, max_depth=None, cmap='rainbow'):
183
  depth = np.array(depth)
184
 
{hoho → hoho2025}/viz3d.py RENAMED
@@ -1,4 +1,3 @@
1
-
2
  """
3
  Copyright [2022] [Paul-Edouard Sarlin and Philipp Lindenberger]
4
 
@@ -21,58 +20,23 @@ Works for a small number of points and cameras, might be slow otherwise.
21
  2) Add 3D points, camera frustums, or both as a pycolmap.Reconstruction
22
 
23
  Written by Paul-Edouard Sarlin and Philipp Lindenberger.
 
24
  """
25
- # Slightly modified by Dmytro Mishkin
26
-
27
  from typing import Optional
28
  import numpy as np
29
  import pycolmap
30
  import plotly.graph_objects as go
31
-
32
-
33
- ### Some helper functions for geometry
34
- def qvec2rotmat(qvec):
35
- return np.array([
36
- [1 - 2 * qvec[2]**2 - 2 * qvec[3]**2,
37
- 2 * qvec[1] * qvec[2] - 2 * qvec[0] * qvec[3],
38
- 2 * qvec[3] * qvec[1] + 2 * qvec[0] * qvec[2]],
39
- [2 * qvec[1] * qvec[2] + 2 * qvec[0] * qvec[3],
40
- 1 - 2 * qvec[1]**2 - 2 * qvec[3]**2,
41
- 2 * qvec[2] * qvec[3] - 2 * qvec[0] * qvec[1]],
42
- [2 * qvec[3] * qvec[1] - 2 * qvec[0] * qvec[2],
43
- 2 * qvec[2] * qvec[3] + 2 * qvec[0] * qvec[1],
44
- 1 - 2 * qvec[1]**2 - 2 * qvec[2]**2]])
45
-
46
 
47
  def to_homogeneous(points):
48
  pad = np.ones((points.shape[:-1]+(1,)), dtype=points.dtype)
49
  return np.concatenate([points, pad], axis=-1)
50
 
51
- def t_to_proj_center(qvec, tvec):
52
- Rr = qvec2rotmat(qvec)
53
- tt = (-Rr.T) @ tvec
54
- return tt
55
-
56
- def calib(params):
57
- out = np.eye(3)
58
- if len(params) == 3:
59
- out[0,0] = params[0]
60
- out[1,1] = params[0]
61
- out[0,2] = params[1]
62
- out[1,2] = params[2]
63
- else:
64
- out[0,0] = params[0]
65
- out[1,1] = params[1]
66
- out[0,2] = params[2]
67
- out[1,2] = params[3]
68
- return out
69
-
70
-
71
  ### Plotting functions
72
 
73
  def init_figure(height: int = 800) -> go.Figure:
74
  """Initialize a 3D figure."""
75
- fig = go.Figure()
76
  axes = dict(
77
  visible=False,
78
  showbackground=False,
@@ -118,9 +82,14 @@ def plot_lines_3d(
118
  x = pts[..., 0]
119
  y = pts[..., 1]
120
  z = pts[..., 2]
121
- traces = [go.Scatter3d(x=x1, y=y1, z=z1,
 
 
 
 
 
122
  mode='lines',
123
- line=dict(color=color, width=2)) for x1, y1, z1 in zip(x,y,z)]
124
  for t in traces:
125
  fig.add_trace(t)
126
  fig.update_traces(showlegend=False)
@@ -150,7 +119,11 @@ def plot_camera(
150
  name: Optional[str] = None,
151
  legendgroup: Optional[str] = None,
152
  size: float = 1.0):
153
- """Plot a camera frustum from pose and intrinsic matrix."""
 
 
 
 
154
  W, H = K[0, 2]*2, K[1, 2]*2
155
  corners = np.array([[0, 0], [W, 0], [W, H], [0, H], [0, 0]])
156
  if size is not None:
@@ -197,106 +170,118 @@ def plot_camera_colmap(
197
  name: Optional[str] = None,
198
  **kwargs):
199
  """Plot a camera frustum from PyCOLMAP objects"""
200
- intr = calib(camera.params)
201
- if intr[0][0] > 10000:
 
202
  print("Bad camera")
203
  return
 
204
  plot_camera(
205
  fig,
206
- qvec2rotmat(image.qvec).T,
207
- t_to_proj_center(image.qvec, image.tvec),
208
- intr,#calibration_matrix(),
209
- name=name or str(image.id),
210
  **kwargs)
211
 
212
 
213
  def plot_cameras(
214
  fig: go.Figure,
215
- reconstruction,#: pycolmap.Reconstruction,
216
  **kwargs):
217
  """Plot a camera as a cone with camera frustum."""
218
- for image_id, image in reconstruction["images"].items():
 
 
219
  plot_camera_colmap(
220
- fig, image, reconstruction["cameras"][image.camera_id], **kwargs)
221
 
222
 
223
  def plot_reconstruction(
224
  fig: go.Figure,
225
- rec,
226
  color: str = 'rgb(0, 0, 255)',
227
  name: Optional[str] = None,
228
  points: bool = True,
229
  cameras: bool = True,
230
  cs: float = 1.0,
231
  single_color_points=False,
232
- camera_color='rgba(0, 255, 0, 0.5)'):
233
- # rec is result of loading reconstruction from "read_write_colmap.py"
 
234
  # Filter outliers
235
  xyzs = []
236
  rgbs = []
237
- for k, p3D in rec['points'].items():
 
 
238
  xyzs.append(p3D.xyz)
239
- rgbs.append(p3D.rgb)
240
-
241
- if points:
242
- plot_points(fig, np.array(xyzs), color=color if single_color_points else np.array(rgbs), ps=1, name=name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  if cameras:
244
  plot_cameras(fig, rec, color=camera_color, legendgroup=name, size=cs)
245
 
246
-
247
- def plot_pointcloud(
248
  fig: go.Figure,
249
- pts: np.ndarray,
250
- colors: np.ndarray,
251
- ps: int = 2,
252
- name: Optional[str] = None):
253
- """Plot a set of 3D points."""
254
- plot_points(fig, np.array(pts), color=colors, ps=ps, name=name)
255
-
256
-
257
- def plot_triangle_mesh(
258
- fig: go.Figure,
259
- vert: np.ndarray,
260
- colors: np.ndarray,
261
- triangles: np.ndarray,
262
- name: Optional[str] = None):
263
- """Plot a triangle mesh."""
264
- tr = go.Mesh3d(
265
- x=vert[:,0],
266
- y=vert[:,1],
267
- z=vert[:,2],
268
- vertexcolor = np.clip(255*colors, 0, 255),
269
- # i, j and k give the vertices of triangles
270
- # here we represent the 4 triangles of the tetrahedron surface
271
- i=triangles[:,0],
272
- j=triangles[:,1],
273
- k=triangles[:,2],
274
- name=name,
275
- showscale=False
276
- )
277
- fig.add_trace(tr)
278
-
279
- def plot_estimate_and_gt(pred_vertices, pred_connections, gt_vertices=None, gt_connections=None):
280
- fig3d = init_figure()
281
- c1 = (30, 20, 255)
282
- img_color = [c1 for _ in range(len(pred_vertices))]
283
- plot_points(fig3d, pred_vertices, color = img_color, ps = 10)
284
- lines = []
285
- for c in pred_connections:
286
- v1 = pred_vertices[c[0]]
287
- v2 = pred_vertices[c[1]]
288
- lines.append(np.stack([v1, v2], axis=0))
289
- plot_lines_3d(fig3d, np.array(lines), img_color, ps=4)
290
  if gt_vertices is not None:
291
- c2 = (30, 255, 20)
292
- img_color2 = [c2 for _ in range(len(gt_vertices))]
293
- plot_points(fig3d, gt_vertices, color = img_color2, ps = 10)
294
  if gt_connections is not None:
295
  gt_lines = []
296
  for c in gt_connections:
297
  v1 = gt_vertices[c[0]]
298
  v2 = gt_vertices[c[1]]
299
  gt_lines.append(np.stack([v1, v2], axis=0))
300
- plot_lines_3d(fig3d, np.array(gt_lines), img_color2, ps=4)
301
- fig3d.show()
302
- return fig3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Copyright [2022] [Paul-Edouard Sarlin and Philipp Lindenberger]
3
 
 
20
  2) Add 3D points, camera frustums, or both as a pycolmap.Reconstruction
21
 
22
  Written by Paul-Edouard Sarlin and Philipp Lindenberger.
23
+ Slightly modified by Dmytro Mishkin
24
  """
 
 
25
  from typing import Optional
26
  import numpy as np
27
  import pycolmap
28
  import plotly.graph_objects as go
29
+ from hoho2025.color_mappings import edge_color_mapping, EDGE_CLASSES_BY_ID
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def to_homogeneous(points):
32
  pad = np.ones((points.shape[:-1]+(1,)), dtype=points.dtype)
33
  return np.concatenate([points, pad], axis=-1)
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  ### Plotting functions
36
 
37
  def init_figure(height: int = 800) -> go.Figure:
38
  """Initialize a 3D figure."""
39
+ fig = go.FigureWidget()
40
  axes = dict(
41
  visible=False,
42
  showbackground=False,
 
82
  x = pts[..., 0]
83
  y = pts[..., 1]
84
  z = pts[..., 2]
85
+ if isinstance(color, list):
86
+ traces = [go.Scatter3d(x=x1, y=y1, z=z1,
87
+ mode='lines',
88
+ line=dict(color=f"rgb{c}", width=ps)) for x1, y1, z1, c in zip(x,y,z,color)]
89
+ else:
90
+ traces = [go.Scatter3d(x=x1, y=y1, z=z1,
91
  mode='lines',
92
+ line=dict(color=color, width=ps)) for x1, y1, z1 in zip(x,y,z)]
93
  for t in traces:
94
  fig.add_trace(t)
95
  fig.update_traces(showlegend=False)
 
119
  name: Optional[str] = None,
120
  legendgroup: Optional[str] = None,
121
  size: float = 1.0):
122
+ """Plot a camera frustum from pose and intrinsic matrix. R and t are
123
+ world_to_camera transformation"""
124
+ R = np.array(R)
125
+ t = np.array(t).reshape(3)
126
+ K = np.array(K)
127
  W, H = K[0, 2]*2, K[1, 2]*2
128
  corners = np.array([[0, 0], [W, 0], [W, H], [0, H], [0, 0]])
129
  if size is not None:
 
170
  name: Optional[str] = None,
171
  **kwargs):
172
  """Plot a camera frustum from PyCOLMAP objects"""
173
+ # Use camera intrinsics method if available, otherwise fallback to params
174
+ intr = camera.calibration_matrix()
175
+ if intr[0][0] > 5000:
176
  print("Bad camera")
177
  return
178
+ world_t_camera = image.cam_from_world.inverse()
179
  plot_camera(
180
  fig,
181
+ world_t_camera.rotation.matrix(), # Use rotation matrix method (World-to-Camera)
182
+ world_t_camera.translation, # Use camera center in world coordinates
183
+ intr,
184
+ name=name or str(image.name),
185
  **kwargs)
186
 
187
 
188
  def plot_cameras(
189
  fig: go.Figure,
190
+ reconstruction: pycolmap.Reconstruction, # Added type hint
191
  **kwargs):
192
  """Plot a camera as a cone with camera frustum."""
193
+ # Iterate over reconstruction.images
194
+ for image_id, image in reconstruction.images.items():
195
+ # Access camera using reconstruction.cameras
196
  plot_camera_colmap(
197
+ fig, image, reconstruction.cameras[image.camera_id], **kwargs)
198
 
199
 
200
  def plot_reconstruction(
201
  fig: go.Figure,
202
+ rec: pycolmap.Reconstruction, # Added type hint
203
  color: str = 'rgb(0, 0, 255)',
204
  name: Optional[str] = None,
205
  points: bool = True,
206
  cameras: bool = True,
207
  cs: float = 1.0,
208
  single_color_points=False,
209
+ camera_color='rgba(0, 255, 0, 0.5)',
210
+ crop_outliers: bool = False):
211
+ # rec is a pycolmap.Reconstruction object
212
  # Filter outliers
213
  xyzs = []
214
  rgbs = []
215
+ # Iterate over rec.points3D
216
+ for k, p3D in rec.points3D.items():
217
+ #print (p3D)
218
  xyzs.append(p3D.xyz)
219
+ rgbs.append(p3D.color)
220
+
221
+ xyzs = np.array(xyzs)
222
+ rgbs = np.array(rgbs)
223
+
224
+ # Crop outliers if requested
225
+ if crop_outliers and len(xyzs) > 0:
226
+ # Calculate distances from origin
227
+ distances = np.linalg.norm(xyzs, axis=1)
228
+ # Find threshold at 98th percentile (removing 2% furthest points)
229
+ threshold = np.percentile(distances, 98)
230
+ # Filter points
231
+ mask = distances <= threshold
232
+ xyzs = xyzs[mask]
233
+ rgbs = rgbs[mask]
234
+ print(f"Cropped outliers: removed {np.sum(~mask)} out of {len(mask)} points ({np.sum(~mask)/len(mask)*100:.2f}%)")
235
+
236
+ if points and len(xyzs) > 0:
237
+ plot_points(fig, xyzs, color=color if single_color_points else rgbs, ps=1, name=name)
238
  if cameras:
239
  plot_cameras(fig, rec, color=camera_color, legendgroup=name, size=cs)
240
 
241
+ def plot_wireframe(
 
242
  fig: go.Figure,
243
+ vertices: np.ndarray,
244
+ edges: np.ndarray,
245
+ classifications: np.ndarray = None,
246
+ color: str = 'rgb(0, 0, 255)',
247
+ name: Optional[str] = None,
248
+ **kwargs):
249
+ """Plot a camera as a cone with camera frustum."""
250
+ gt_vertices = np.array(vertices)
251
+ gt_connections = np.array(edges)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  if gt_vertices is not None:
253
+ img_color2 = [color for _ in range(len(gt_vertices))]
254
+ plot_points(fig, gt_vertices, color = img_color2, ps = 10)
 
255
  if gt_connections is not None:
256
  gt_lines = []
257
  for c in gt_connections:
258
  v1 = gt_vertices[c[0]]
259
  v2 = gt_vertices[c[1]]
260
  gt_lines.append(np.stack([v1, v2], axis=0))
261
+ if classifications is not None and len(classifications) == len(gt_lines):
262
+ line_colors = []
263
+ for c in classifications:
264
+ line_colors.append(edge_color_mapping[EDGE_CLASSES_BY_ID[c]])
265
+ plot_lines_3d(fig, np.array(gt_lines), line_colors, ps=4)
266
+ else:
267
+ plot_lines_3d(fig, np.array(gt_lines), color, ps=4)
268
+
269
+
270
+ def plot_bpo_cameras_from_entry(fig: go.Figure, entry: dict, idx = None):
271
+ def cam2world_to_world2cam(R, t):
272
+ rt = np.eye(4)
273
+ rt[:3,:3] = R
274
+ rt[:3,3] = t.reshape(-1)
275
+ rt = np.linalg.inv(rt)
276
+ return rt[:3,:3], rt[:3,3]
277
+
278
+ for i in range(len(entry['R'])):
279
+ if idx is not None and i != idx:
280
+ continue
281
+ K = np.array(entry['K'][i])
282
+ R = np.array(entry['R'][i])
283
+ t = np.array(entry['t'][i])
284
+ R, t = cam2world_to_world2cam(R, t)
285
+ plot_camera(fig, R, t, K)
286
+
287
+
notebooks/example.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,10 +1,14 @@
1
  datasets
 
2
  ipywidgets
3
  matplotlib
4
  numpy
5
- pillow
 
6
  plotly
7
  pycolmap
8
  scipy
 
9
  trimesh
10
- webdataset
 
 
1
  datasets
2
+ huggingface-hub
3
  ipywidgets
4
  matplotlib
5
  numpy
6
+ opencv-python
7
+ Pillow
8
  plotly
9
  pycolmap
10
  scipy
11
+ torch
12
  trimesh
13
+ webdataset
14
+ manifold3d # for metric computation
setup.py CHANGED
@@ -5,12 +5,13 @@ import glob
5
  with open('requirements.txt') as f:
6
  required = f.read().splitlines()
7
 
8
- setup(name='hoho',
9
- version='0.0.4',
10
  description='Tools and utilites for the HoHo Dataset and S23DR Competition',
11
  url='usm3d.github.io',
12
  author='Jack Langerman, Dmytro Mishkin, S23DR Orgainizing Team',
13
  author_email='hoho@jackml.com',
14
  install_requires=required,
15
  packages=find_packages(),
 
16
  include_package_data=True)
 
5
  with open('requirements.txt') as f:
6
  required = f.read().splitlines()
7
 
8
+ setup(name='hoho2025',
9
+ version='0.1.0',
10
  description='Tools and utilites for the HoHo Dataset and S23DR Competition',
11
  url='usm3d.github.io',
12
  author='Jack Langerman, Dmytro Mishkin, S23DR Orgainizing Team',
13
  author_email='hoho@jackml.com',
14
  install_requires=required,
15
  packages=find_packages(),
16
+ python_requires='>=3.10',
17
  include_package_data=True)