├── .gitignore ├── example.png ├── example_graph.png ├── rplanpy ├── __init__.py ├── metadata.py ├── plot.py ├── utils.py └── data.py ├── requirements.txt ├── .github └── workflows │ └── pip-upload.yml ├── create_graph.py ├── LICENSE ├── setup.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *pyc 3 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unaisaralegui/rplanpy/HEAD/example.png -------------------------------------------------------------------------------- /example_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unaisaralegui/rplanpy/HEAD/example_graph.png -------------------------------------------------------------------------------- /rplanpy/__init__.py: -------------------------------------------------------------------------------- 1 | from . import data 2 | from . import utils 3 | from . import plot 4 | -------------------------------------------------------------------------------- /rplanpy/metadata.py: -------------------------------------------------------------------------------- 1 | major, minor, patch = (0, 1, 1) 2 | __version__ = f"{major}.{minor}.{patch}" 3 | __author__ = "Unai Saralegui" 4 | __email__ = "usaralegui@gmail.com" 5 | __credits__ = ["Unai Saralegui"] 6 | __maintainer__ = "Unai Saralegui" 7 | __status__ = "Development" 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cycler==0.10.0 2 | decorator==4.4.2 3 | imageio==2.9.0 4 | kiwisolver==1.3.1 5 | matplotlib==3.4.1 6 | networkx==2.5.1 7 | numpy==1.20.2 8 | Pillow==8.2.0 9 | pyparsing==2.4.7 10 | python-dateutil==2.8.1 11 | PyWavelets==1.1.1 12 | scikit-image==0.18.1 13 | scipy==1.6.3 14 | six==1.15.0 15 | tifffile==2021.4.8 16 | -------------------------------------------------------------------------------- /.github/workflows/pip-upload.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Prepare repo 10 | uses: actions/checkout@master 11 | - name: Set up Python 3.7 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.7 15 | - name: Install pypa/build 16 | run: >- 17 | python -m 18 | pip install 19 | build 20 | --user 21 | - name: Build a binary wheel and a source tarball 22 | run: >- 23 | python -m 24 | build 25 | --sdist 26 | --wheel 27 | --outdir dist/ 28 | . 29 | - name: Upload 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | with: 32 | user: __token__ 33 | password: ${{ secrets.PYPI_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /create_graph.py: -------------------------------------------------------------------------------- 1 | import imageio 2 | import matplotlib.pyplot as plt 3 | 4 | import rplanpy 5 | 6 | 7 | def test_functions(file: str, out_file: str = 'example_graph.png', plot_original: bool = True) -> None: 8 | data = rplanpy.data.RplanData(file) 9 | ncols = 3 if plot_original else 2 10 | fig, ax = plt.subplots(nrows=1, ncols=ncols, figsize=(15, 5)) 11 | if plot_original: 12 | image = imageio.imread(file) 13 | ax[0].imshow(image) 14 | ax[0].axis("off") 15 | ax[0].set_title("Original image") 16 | rplanpy.plot.plot_floorplan(data, ax=ax[plot_original+0], title="Rooms and doors") 17 | ax = rplanpy.plot.plot_floorplan_graph( 18 | data=data, with_colors=True, edge_label='door', ax=ax[plot_original+1], 19 | title="Building graph" 20 | ) 21 | plt.tight_layout() 22 | plt.savefig(out_file) 23 | 24 | plt.show() 25 | 26 | 27 | if __name__ == '__main__': 28 | file = 'example.png' 29 | test_functions(file, out_file='example_graph.png', plot_original=True) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Unai Saralegui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from distutils.util import convert_path 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | metadata = {} 8 | ver_path = convert_path('rplanpy/metadata.py') 9 | with open(ver_path) as ver_file: 10 | exec(ver_file.read(), metadata) 11 | 12 | 13 | setuptools.setup( 14 | name="rplanpy", 15 | version=metadata['__version__'], 16 | author=metadata['__author__'], 17 | author_email=metadata['__email__'], 18 | description="Set of python utilities to work with the RPLAN dataset from \"[Data-driven Interior Plan Generation for Residential Buildings](https://doi.org/10.1145/3355089.3356556)\" paper.", 19 | long_description=long_description, 20 | long_description_content_type="text/markdown", 21 | url="https://github.com/unaisaralegui/rplanpy", 22 | packages=setuptools.find_packages(), 23 | install_requires=[ 24 | 'imageio', 25 | 'numpy', 26 | 'matplotlib', 27 | 'networkx', 28 | 'scikit-image', 29 | 'scipy' 30 | ], 31 | classifiers=[ 32 | "Programming Language :: Python :: 3", 33 | "License :: OSI Approved :: MIT License", 34 | "Operating System :: OS Independent", 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rplanpy 2 | 3 | Set of python utilities to work with the RPLAN dataset from 4 | "[Data-driven Interior Plan Generation for Residential Buildings](https://doi.org/10.1145/3355089.3356556)" paper. 5 | 6 | 7 | ## Data 8 | 9 | This package works with the RPLAN image dataset, which consists of more than 80k floorplan 10 | images of real residential buildings, and can be obtained from the following 11 | [link](https://docs.google.com/forms/d/e/1FAIpQLSfwteilXzURRKDI5QopWCyOGkeb_CFFbRwtQ0SOPhEg0KGSfw/viewform) 12 | 13 | ## Examples 14 | 15 | ### Floorplan image to relation graph 16 | 17 | Check an example converting a floorplan to a relation graph with node and edge attibutes in 18 | [create_graph.py](create_graph.py). `True` in an edge means there is a door in the connection between two rooms. 19 | 20 | ![Graph example image](example_graph.png) 21 | 22 | ## Citation 23 | 24 | If you use this work please cite the original paper which collected the data. 25 | 26 | ``` 27 | @article{wu2019data, 28 | title={Data-driven interior plan generation for residential buildings}, 29 | author={Wu, Wenming and Fu, Xiao-Ming and Tang, Rui and Wang, Yuhan and Qi, Yu-Hao and Liu, Ligang}, 30 | journal={ACM Transactions on Graphics (TOG)}, 31 | volume={38}, 32 | number={6}, 33 | pages={1--12}, 34 | year={2019}, 35 | publisher={ACM New York, NY, USA} 36 | } 37 | 38 | ``` 39 | -------------------------------------------------------------------------------- /rplanpy/plot.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import networkx as nx 3 | import numpy as np 4 | 5 | from . import data 6 | from . import utils 7 | 8 | 9 | def floorplan_to_color(data: data.RplanData): 10 | """ 11 | Get an image like object with the color for each pixel 12 | 13 | :param data: a data object from RPLAN dataset 14 | :type data: data.RplanData 15 | :return: list of colors for each pixel 16 | :rtype: list 17 | """ 18 | coloured_image = [[utils.ROOM_COLOR[pix] for pix in row] for row in data.category] 19 | return coloured_image 20 | 21 | 22 | def plot_floorplan(data: data.RplanData, ax=None, title=None): 23 | """ 24 | Plot a floorplan 25 | 26 | :param data: a data object from RPLAN dataset 27 | :type data: data.RplanData 28 | :param ax: optional axes to plot in 29 | :type ax: matplotlib.axes._subplots.AxesSubplot 30 | :param title: optional title to add to the plot 31 | :type title: str 32 | :return: the plotted axes 33 | :rtype: matplotlib.axes._subplots.AxesSubplot 34 | """ 35 | coloured_image = floorplan_to_color(data) 36 | if ax is None: 37 | ax = plt.subplot() 38 | ax.imshow(coloured_image) 39 | ax.set_title(title) 40 | ax.axis("off") 41 | return ax 42 | 43 | 44 | def plot_floorplan_graph(data: data.RplanData, ax=None, title=None, 45 | with_colors=True, edge_label=None): 46 | """ 47 | Plot the graph representation for a floorplan 48 | 49 | :param data: a data object from RPLAN dataset 50 | :type data: data.RplanData 51 | :param ax: optional axes to plot in 52 | :type ax: matplotlib.axes._subplots.AxesSubplot 53 | :param title: optional title to add to the plot 54 | :type title: str 55 | :param with_colors: optional, whether to colour nodes by their class or not, defaults to True 56 | :type with_colors: bool 57 | :return: the plotted axes 58 | :rtype: matplotlib.axes._subplots.AxesSubplot 59 | """ 60 | if ax is None: 61 | ax = plt.subplot() 62 | G = data.get_graph() 63 | pos = nx.spring_layout(G) 64 | if with_colors: 65 | colors = [np.array(utils.ROOM_COLOR.get(G.nodes[n]['category'], [255, 255, 255]))/255 for n in G.nodes] 66 | nx.draw(G, pos, with_labels=True, ax=ax, node_color=colors) 67 | else: 68 | nx.draw(G, pos, with_labels=True, ax=ax, ) 69 | if edge_label: 70 | edge_labels = nx.get_edge_attributes(G, edge_label) 71 | nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, ax=ax) 72 | ax.set_title(title) 73 | return ax 74 | -------------------------------------------------------------------------------- /rplanpy/utils.py: -------------------------------------------------------------------------------- 1 | ROOM_CLASS = { 2 | 0: "Living room", 3 | 1: "Master room", 4 | 2: "Kitchen", 5 | 3: "Bathroom", 6 | 4: "Dining room", 7 | 5: "Child room", 8 | 6: "Study room", 9 | 7: "Second room", 10 | 8: "Guest room", 11 | 9: "Balcony", 12 | 10: "Entrance", 13 | 11: "Storage", 14 | 12: "Wall-in", 15 | 13: "External area", 16 | 14: "Exterior wall", 17 | 15: "Front door", 18 | 16: "Interior wall", 19 | 17: "Interior door", 20 | } 21 | 22 | ROOM_TYPE = { 23 | 0: 'PublicArea', 24 | 1: 'Bedroom', 25 | 2: 'FunctionArea', 26 | 3: 'FunctionArea', 27 | 4: 'FunctionArea', 28 | 5: 'Bedroom', 29 | 6: 'Bedroom', 30 | 7: 'Bedroom', 31 | 8: 'Bedroom', 32 | 9: 'PublicArea', 33 | 10: 'PublicArea', 34 | 11: 'PublicArea', 35 | 12: 'PublicArea', 36 | 13: 'External', 37 | 14: 'ExteriorWall', 38 | 15: 'FrontDoor', 39 | 16: 'InteriorWall', 40 | 17: 'InteriorDoor', 41 | } 42 | 43 | ROOM_COLOR = { 44 | 0: [244, 242, 229], 45 | 1: [253, 244, 171], 46 | 2: [234, 216, 214], 47 | 3: [205, 233, 252], 48 | 4: [244, 242, 229], 49 | 5: [253, 244, 171], 50 | 6: [253, 244, 171], 51 | 7: [253, 244, 171], 52 | 8: [253, 244, 171], 53 | 9: [208, 216, 135], 54 | 10: [244, 242, 229], 55 | 11: [249, 222, 189], 56 | 12: [128, 128, 128], 57 | 13: [255, 255, 255], 58 | 14: [79, 79, 79], 59 | 15: [255, 225, 25], 60 | 16: [128, 128, 128], 61 | 17: [255, 225, 25], 62 | } 63 | 64 | 65 | def get_image_channel(image, n): 66 | """ 67 | Get channel n of an image 68 | 69 | :param image: An image loaded with imageio 70 | :param n: channel to get 71 | :return: values in specified channel 72 | """ 73 | return image[..., n] 74 | 75 | 76 | def collide2d(bbox1, bbox2, th=0): 77 | """ 78 | Do two boxes collide with each other? 79 | :param bbox1: indexes of box 1 (y0, x0, y1, x1) 80 | :param bbox2: indexes of box 2 (y0, x0, y1, x1) 81 | :param th: optional margin to add to the boxes (default 0) 82 | :return: True/False depending if boxes do collide 83 | :rtype: bool 84 | """ 85 | return not( 86 | (bbox1[0]-th > bbox2[2]) or 87 | (bbox1[2]+th < bbox2[0]) or 88 | (bbox1[1]-th > bbox2[3]) or 89 | (bbox1[3]+th < bbox2[1]) 90 | ) 91 | 92 | 93 | def point_box_relation(u, vbox): 94 | """ 95 | Check in which point is located related to a box 96 | :param u: point to check (y, x) 97 | :param vbox: box to check point with (y0, x0, y1, x1) 98 | :return: code with the location of the point 99 | 0 3 8 100 | --- 101 | 2 | 4 | 7 102 | --- 103 | 1 6 9 104 | """ 105 | uy, ux = u 106 | vy0, vx0, vy1, vx1 = vbox 107 | if (ux < vx0 and uy <= vy0) or (ux == vx0 and uy == vy0): 108 | relation = 0 # 'left-above' 109 | elif vx0 <= ux < vx1 and uy <= vy0: 110 | relation = 3 # 'above' 111 | elif (vx1 <= ux and uy < vy0) or (ux == vx1 and uy == vy0): 112 | relation = 8 # 'right-above' 113 | elif vx1 <= ux and vy0 <= uy < vy1: 114 | relation = 7 # 'right-of' 115 | elif (vx1 < ux and vy1 <= uy) or (ux == vx1 and uy == vy1): 116 | relation = 9 # 'right-below' 117 | elif vx0 < ux <= vx1 and vy1 <= uy: 118 | relation = 6 # 'below' 119 | elif (ux <= vx0 and vy1 < uy) or (ux == vx0 and uy == vy1): 120 | relation = 1 # 'left-below' 121 | elif ux <= vx0 and vy0 < uy <= vy1: 122 | relation = 2 # 'left-of' 123 | elif vx0 < ux < vx1 and vy0 < uy < vy1: 124 | relation = 4 # 'inside' 125 | else: 126 | relation = None 127 | return relation 128 | 129 | 130 | def door_room_relation(door_box, room_box): 131 | y0, x0, y1, x1 = room_box[:4] 132 | yc, xc = (y1 + y0) / 2, (x0 + x1) / 2 133 | y0b, x0b, y1b, x1b = door_box[:4] 134 | y, x = (y1b + y0b) / 2, (x0b + x1b) / 2 135 | 136 | if x == xc and y < yc: 137 | return 7 138 | elif x == xc and y > yc: 139 | return 1 140 | elif y == yc and x < xc: 141 | return 10 142 | elif y == yc and x > xc: 143 | return 4 144 | elif x0 < x < xc: 145 | if y < yc: 146 | return 8 147 | else: 148 | return 12 149 | elif xc < x < x1: 150 | if y < yc: 151 | return 6 152 | else: 153 | return 2 154 | elif y0 < y < yc: 155 | if x < xc: 156 | return 9 157 | else: 158 | return 5 159 | elif yc < y < y1: 160 | if x < xc: 161 | return 11 162 | else: 163 | return 3 164 | else: 165 | return 0 166 | -------------------------------------------------------------------------------- /rplanpy/data.py: -------------------------------------------------------------------------------- 1 | import imageio 2 | import numpy as np 3 | import networkx as nx 4 | from skimage import measure, feature, segmentation 5 | from scipy import stats, ndimage 6 | 7 | 8 | from . import utils 9 | 10 | 11 | class RplanData: 12 | """ 13 | Set of utilities for RPLAN dataset images 14 | 15 | :param image_path: Path to the image from the RPLAN dataset 16 | :type image_path: str 17 | """ 18 | 19 | def __init__(self, image_path): 20 | self.image_path = image_path 21 | self.image = imageio.imread(image_path) 22 | self.boundary = utils.get_image_channel(self.image, 0) 23 | self.category = utils.get_image_channel(self.image, 1) 24 | self.instance = utils.get_image_channel(self.image, 2) 25 | self.inside = utils.get_image_channel(self.image, 3) 26 | self._rooms = None 27 | self._edges = None 28 | self._interior_doors = None 29 | 30 | def get_front_door_mask(self) -> np.array: 31 | """ 32 | Get the mask for the front door 33 | 34 | :returns: Bounding box (min_row, min_col, max_row, max_col). Pixels belonging to the bounding box for the front door 35 | :rtype: np.ndarray 36 | """ 37 | front_door_mask = self.boundary == 255 38 | region = measure.regionprops(front_door_mask.astype(int))[0] 39 | return np.array(region.bbox, dtype=int) 40 | 41 | def get_rooms(self) -> np.array: 42 | """ 43 | Get the rooms in the floor plan 44 | :return: 45 | """ 46 | if self._rooms is not None: 47 | return self._rooms 48 | rooms = [] 49 | regions = measure.regionprops(self.instance) 50 | for region in regions: 51 | # c is the most common value in the region, 52 | # in this case c is the category of the room (channel 2) 53 | c = stats.mode( 54 | self.category[region.coords[:, 0], 55 | region.coords[:, 1]], 56 | axis=None 57 | )[0][0] 58 | i = stats.mode( 59 | self.instance[region.coords[:, 0], 60 | region.coords[:, 1]], 61 | axis=None 62 | )[0][0] 63 | y0, x0, y1, x1 = np.array(region.bbox) 64 | rooms.append([y0, x0, y1, x1, c, i]) 65 | self._rooms = np.array(rooms, dtype=int) 66 | return self._rooms 67 | 68 | def get_edges(self, th: int = 9) -> np.array: 69 | if self._edges is not None: 70 | return self._edges 71 | if self._rooms is None: 72 | self.get_rooms() 73 | edges = [] 74 | for u in range(len(self._rooms)): 75 | for v in range(u + 1, len(self._rooms)): 76 | # check if two rooms are connected, if they are check the spatial relation between them 77 | if not utils.collide2d(self._rooms[u, :4], self._rooms[v, :4], th=th): 78 | # rooms are not connected 79 | continue 80 | uy0, ux0, uy1, ux1, c1, i1 = self._rooms[u] 81 | vy0, vx0, vy1, vx1, c2, i2 = self._rooms[v] 82 | uc = (uy0 + uy1) / 2, (ux0 + ux1) / 2 83 | vc = (vy0 + vy1) / 2, (vx0 + vx1) / 2 84 | if ux0 < vx0 and ux1 > vx1 and uy0 < vy0 and uy1 > vy1: 85 | relation = 5 # 'surrounding' 86 | elif ux0 >= vx0 and ux1 <= vx1 and uy0 >= vy0 and uy1 <= vy1: 87 | relation = 4 # 'inside' 88 | else: 89 | relation = utils.point_box_relation(uc, self._rooms[v, :4]) 90 | edges.append([u, v, relation]) 91 | self._edges = np.array(edges, dtype=int) 92 | return self._edges 93 | 94 | def get_interior_doors(self) -> np.array: 95 | if self._interior_doors is not None: 96 | return self._interior_doors 97 | doors = [] 98 | category = 17 # InteriorDoor 99 | mask = (self.category == category).astype(np.uint8) 100 | distance = ndimage.morphology.distance_transform_cdt(mask) 101 | local_maxi = (distance > 1).astype(np.uint8) 102 | corner_measurement = feature.corner_harris(local_maxi) 103 | local_maxi[corner_measurement > 0] = 0 104 | markers = measure.label(local_maxi) 105 | 106 | labels = segmentation.watershed(-distance, markers, mask=mask, connectivity=8) 107 | regions = measure.regionprops(labels) 108 | 109 | for region in regions: 110 | y0, x0, y1, x1 = np.array(region.bbox) 111 | doors.append([y0, x0, y1, x1, category]) 112 | 113 | self._interior_doors = np.array(doors, dtype=int) 114 | return self._interior_doors 115 | 116 | def get_rooms_with_properties(self) -> dict: 117 | """ 118 | Get each room with their properties formatted in a dict 119 | 120 | :return: dictionary with room id as key and its category and additional properties as values 121 | :rtype: dict 122 | """ 123 | properties_per_room = { 124 | room[-1]: { 125 | 'category': room[-2], 126 | 'bounding_box': room[0:4], 127 | 'area': (room[2]-room[0]) * (room[3]-room[1]) 128 | } 129 | for room in self.get_rooms() 130 | } 131 | return properties_per_room 132 | 133 | def door_in_edge(self, edge: list) -> bool: 134 | """ 135 | Check if there is a door in an edge 136 | 137 | :param edge: edge to check [room1, room2, relation] 138 | :type edge: list 139 | :return: door in an edge? 140 | :rtype: bool 141 | """ 142 | doors = self.get_interior_doors() 143 | room1 = self.get_rooms()[edge[0]] 144 | room2 = self.get_rooms()[edge[1]] 145 | for i in range(len(doors)): 146 | if utils.door_room_relation(doors[i], room1) and utils.door_room_relation(doors[i], room2): 147 | return True 148 | return False 149 | 150 | def get_edges_with_properties(self) -> list: 151 | """ 152 | Get edges between rooms with their properties formatted in a dict 153 | 154 | :return: list of edges and edge properties [(u, v, props)] 155 | :rtype: list 156 | """ 157 | rooms = self.get_rooms() 158 | properties_per_edge = [ 159 | ( 160 | rooms[edge[0]][-1], rooms[edge[1]][-1], 161 | { 162 | 'location': edge[2], 163 | 'door': self.door_in_edge(edge) 164 | } 165 | ) 166 | for edge in self.get_edges() 167 | ] 168 | return properties_per_edge 169 | 170 | def get_graph(self) -> nx.classes.graph.Graph: 171 | """ 172 | Get the graph representation for the floorplan 173 | 174 | :return: a networkx graph with the nodes, the edges and their properties 175 | :rtype: networkx.classes.graph.Graph 176 | """ 177 | G = nx.Graph() 178 | # add nodes 179 | G.add_nodes_from([(room, props) for room, props in self.get_rooms_with_properties().items()]) 180 | # add edges 181 | G.add_edges_from(self.get_edges_with_properties()) 182 | return G 183 | --------------------------------------------------------------------------------