Parsing ASCII floor plan image in python?
Question:
I am trying to identify the number of rooms and furniture(S,C,W,P) in an ASCII floorplan. A typical floorplan looks like this with different rooms and layouts. What would be the best way to tackle this?
+---------------+-------------------+ +----------+
| | | | |
| (office) | C | | C |
| | | | |
| W | +-----------+ |
| | | | |
| S | (bathroom) S| S | |
| +---+--------+----------+ | |
| /P S| | |
| / | | |
| / (kitchen) | (bedroom) | P |
+-------+ | | |
| | | |
| SSWP | W W | |
| +-------------+----------------------+ |
| |
| (hallway) |
| W |
+--------------+-------------+-------------+ |
| | |
| | C |
| P | |
| | |
+------+ P | +----------+
|S |
| (balcony) C |
+--------------------+
Answers:
My approach would be:
- consider the plan as a grid
- split each row in segments of "space" (non-walls)
- join spaces that share at least one column
- look for a name, if found save the joint space as a roon
- count the furnitures in the room
That results in the following code (I defined a couple of classes for readability):
from dataclasses import dataclass, field
from re import finditer, search
from typing import Dict, List
@dataclass
class RowSegment():
row_no : int
col_start : int
col_end : int
def __repr__(self):
return f'{self.row_no}:{self.col_start}-{self.col_end}'
@dataclass
class Space():
name : str = 'noname'
furn_count : Dict[str, int] = field(default_factory = dict)
segs : List[RowSegment] = field(default_factory = list)
complete : bool = False
def __repr__(self):
return f'{self.name}: {[i for i in self.furn_count.items()]}n{[s for s in self.segs]}'
plan = """
+---------------+-------------------+ +----------+
| | | | |
| (office) | C | | C |
| | | | |
| W | +-----------+ |
| | | | |
| S | (bathroom) S| S | |
| +---+--------+----------+ | |
| /P S| | |
| / | | |
| / (kitchen) | (bedroom) | P |
+-------+ | | |
| | | |
| SSWP | W W | |
| +-------------+----------------------+ |
| |
| (hallway) |
| W |
+--------------+-------------+-------------+ |
| | |
| | C |
| P | |
| | |
+------+ P | +----------+
|S |
| (balcony) C |
+--------------------+ """
furn_types = 'CPSW'
workspaces = []
rooms = []
rows = plan.split('n')
rows.pop(0)
for no, row in enumerate(rows):
found = list(finditer(r'[^/\|+-]+', row))
if found:
rsegs = [RowSegment(no, rs.start(), rs.end()) for rs in found]
for ws in workspaces:
for rs in rsegs:
if max(ws.segs[-1].col_start, rs.col_start) < min(ws.segs[-1].col_end, rs.col_end): #add rs to ws
ws.segs.append(rs)
rsegs.remove(rs)
break
else: #no rs added => complete
text = ''.join([rows[s.row_no][s.col_start:s.col_end] for s in ws.segs])
name = search(r'(w+)', text)
if name:
ws.name = name[0][1:-1]
ws.furn_count = {f: text.count(f) for f in furn_types}
rooms.append(ws)
ws.complete = True
#reset ws list to only not complete
workspaces = [ws for ws in workspaces if ws.complete == False]
#create new wss with remaining rss
for rs in rsegs:
newws = Space()
newws.segs.append(rs)
workspaces.append(newws)
for r in rooms:
print(r)
The output (I’m also printing the "spaces" that compose each room) is:
bathroom: [('C', 1), ('P', 0), ('S', 1), ('W', 0)]
[1:17-36, 2:17-36, 3:17-36, 4:17-36, 5:17-36, 6:17-36]
office: [('C', 0), ('P', 0), ('S', 1), ('W', 1)]
[1:1-16, 2:1-16, 3:1-16, 4:1-16, 5:1-16, 6:1-16, 7:1-12, 8:1-11, 9:1-10, 10:1-9]
bedroom: [('C', 0), ('P', 0), ('S', 1), ('W', 2)]
[5:37-48, 6:37-48, 7:37-48, 8:26-48, 9:26-48, 10:26-48, 11:26-48, 12:26-48, 13:26-48]
kitchen: [('C', 0), ('P', 2), ('S', 3), ('W', 1)]
[8:12-25, 9:11-25, 10:10-25, 11:9-25, 12:10-25, 13:11-25]
hallway: [('C', 2), ('P', 1), ('S', 0), ('W', 1)]
[1:49-59, 2:49-59, 3:49-59, 4:49-59, 5:49-59, 6:49-59, 7:49-59, 8:49-59, 9:49-59, 10:49-59, 11:49-59, 12:49-59, 13:49-59, 14:49-59, 15:1-59, 16:1-59, 17:1-59, 18:44-59, 19:45-59, 20:46-59, 21:47-59, 22:48-59]
balcony: [('C', 1), ('P', 2), ('S', 1), ('W', 0)]
[19:16-29, 20:16-29, 21:16-29, 22:16-29, 23:16-29, 24:9-29, 25:9-29]
I would suggest something like:
-
consider the plan as a grid
-
replace all alphabetic letters (and parenthesis) with spaces
-
set as wall every cell with a non-space char
-
set the 1st non-wall cell as room 1
- loop over cells that are "air" near this one and set them as same room and loop over neightboring cells again and again untill you find walls
- when done, take a remaining cell and set it as room X
do again and again until all cells are attributed to different rooms
- compare the original plan (that have letters) with the given rooms you found
Assuming the below as I am inferring it from the (very short and unclear) problem statement (please inform if these are true). Answer is directional at best.
- ASCII plan is stored in txt file in local machine and needs to be read from software interface
- The plan will always be rectangular as the solution does not incorporate any other shape
- the ASCII chanrecters that represent the wall (| and / and and -) corners (+)
- Rooms will always be named in round brackets and will not spill outside room and will not be written overlapping a wall or outside the room
Heuristic
The search of a room and the room name is based on BFS algorithm which one can understand using this arcticle https://medium.com/geekculture/breadth-first-search-bfs-algorithm-with-python-952aea707e93
Python Solution
importing numpy and reading the file from the location on local machine. Maxlength is the length of the rectagular plan
floor_plan = [];
maxlength = 0;
Now we need to read the whole plan so as to have an object on which we will run the BFS
with open('txtfilewithASCIIplan.txt',encoding='utf-8-sig') as file:
for line in file.read().splitlines():
line_list = list(line)
if(len(line_list) > maxlength):
maxlength = len(line_list)
floor_plan.append(line_list)
Create the object now
for _,row in enumerate(floor_plan):
row.extend(list(' '*(maxlength - len(row))))
create the variables to be used in the BFS as described below. Comments are self explainatory
# seperator characters
seperator = {'+', '-', '|', '/', '\'}
#Number of Rows
number_of_rows = len(floor_plan)
#Number of Columns
number_of_columns = len(floor_plan[0])
#Matrix to keep track of points visited
visited = np.zeros(shape=(number_of_rows, number_of_columns), dtype=bool)
# contains a list of tuples; where the first element in tuple is room name and the second element is list of chairs in that room
room_with_chairs = []
# we store all the chairs present in the whole floor plan
list_of_all_letters = []
Do not visit a visited cell or a seperator
def shouldvisit(cell):
x = cell[0]
y = cell[1]
return not(visited[x][y] or (floor_plan[x][y] in seperator))
A sense check for seeing if we are in a diagonal corner which is essentially a Checks if both given cells contain separators
def are_both_separators(cell1, cell2):
return (floor_plan[cell1[0]][cell1[1]] in seperator) and (floor_plan[cell2[0]][cell2[1]] in seperator)
BFS needs to know its neighbours and we collect them here
# gets all the valid adjacent cells which can be at most 8
def get_neighbours(cell):
x = cell[0]
y = cell[1]
neighbours = []
# check for floor plan boundary
y_0 = y > 0 #check if current position is not along left-most verticle edge of floor plan
y_max = y < number_of_columns - 1 #check if current position is not along right-most verticle edge of floor plan
x_0 = x > 0 #check if current position is not along top-most horizontal edge of floor plan
x_max = x < number_of_rows - 1 #check if current position is not along bottom-most horizontal edge of floor plan
# Below is the check for valid neighbours
# The second condition is necessary because while checking a diagonal neighbour, we must make sure that
# the cells along the other diagonal do not contain separators as in that case the diagonal neighbour would be on the other side of the boundary
#check validity of i-1,j-1 & i-1,j+1
if(x_0):
if(y_0 and not are_both_separators((x-1, y), (x, y-1))):
neighbours.append((x-1, y-1))
neighbours.append((x-1, y))
if(y_max) and not are_both_separators((x-1, y), (x, y+1)):
neighbours.append((x-1, y+1))
#check validity of i,j-1
if(y_0):
neighbours.append((x, y-1))
#check validity of i,j+1
if(y_max):
neighbours.append((x, y+1))
##check validity of i+1,j-1 & i+1,j & i+1,j+1
if(x_max):
if(y_0 and not are_both_separators((x+1, y), (x, y-1))):
neighbours.append((x+1, y-1))
neighbours.append((x+1, y))
if(y_max and not are_both_separators((x+1, y), (x, y+1))):
neighbours.append((x+1, y+1))
return neighbours
Now we search for the name of the room
def get_room_name(cell):
x = cell[0]
y = cell[1]
row = ''.join(floor_plan[x]) #for string conversion
l=y;
r=y;
while(l>0 and row[l]!='('):
l-=1;
while(r<len(row)-1 and row[r]!=')'):
r+=1;
return row[l+1:r]
BFS
def bfs(cell):
queue = [cell]
chairs = []
area_name = None
visited[cell[0]][cell[1]] = True
while(queue != []):
curr_cell = queue.pop()
x = curr_cell[0]
y = curr_cell[1]
curr_char = floor_plan[x][y]
if(curr_char.isupper()):
chairs.append(curr_char)
elif((curr_char.islower() or curr_char == '(' or curr_char == ')') and area_name == None):
area_name = get_room_name(curr_cell)
for neighbour in get_neighbours(curr_cell):
if(shouldvisit(neighbour)):
visited[neighbour[0]][neighbour[1]] = True
queue.append(neighbour)
return (area_name, chairs)
Now running the BFS on the plan
# Doing BFS over every unvisited non-separator cell
for i, row in enumerate(floor_plan):
for j, _ in enumerate(row):
if(shouldvisit((i, j))):
local_outcome = bfs((i, j))
if(local_outcome[0] != None):
room_with_chairs.append(local_outcome)
list_of_all_letters.extend(local_outcome[1])
This completes the identification
I am trying to identify the number of rooms and furniture(S,C,W,P) in an ASCII floorplan. A typical floorplan looks like this with different rooms and layouts. What would be the best way to tackle this?
+---------------+-------------------+ +----------+
| | | | |
| (office) | C | | C |
| | | | |
| W | +-----------+ |
| | | | |
| S | (bathroom) S| S | |
| +---+--------+----------+ | |
| /P S| | |
| / | | |
| / (kitchen) | (bedroom) | P |
+-------+ | | |
| | | |
| SSWP | W W | |
| +-------------+----------------------+ |
| |
| (hallway) |
| W |
+--------------+-------------+-------------+ |
| | |
| | C |
| P | |
| | |
+------+ P | +----------+
|S |
| (balcony) C |
+--------------------+
My approach would be:
- consider the plan as a grid
- split each row in segments of "space" (non-walls)
- join spaces that share at least one column
- look for a name, if found save the joint space as a roon
- count the furnitures in the room
That results in the following code (I defined a couple of classes for readability):
from dataclasses import dataclass, field
from re import finditer, search
from typing import Dict, List
@dataclass
class RowSegment():
row_no : int
col_start : int
col_end : int
def __repr__(self):
return f'{self.row_no}:{self.col_start}-{self.col_end}'
@dataclass
class Space():
name : str = 'noname'
furn_count : Dict[str, int] = field(default_factory = dict)
segs : List[RowSegment] = field(default_factory = list)
complete : bool = False
def __repr__(self):
return f'{self.name}: {[i for i in self.furn_count.items()]}n{[s for s in self.segs]}'
plan = """
+---------------+-------------------+ +----------+
| | | | |
| (office) | C | | C |
| | | | |
| W | +-----------+ |
| | | | |
| S | (bathroom) S| S | |
| +---+--------+----------+ | |
| /P S| | |
| / | | |
| / (kitchen) | (bedroom) | P |
+-------+ | | |
| | | |
| SSWP | W W | |
| +-------------+----------------------+ |
| |
| (hallway) |
| W |
+--------------+-------------+-------------+ |
| | |
| | C |
| P | |
| | |
+------+ P | +----------+
|S |
| (balcony) C |
+--------------------+ """
furn_types = 'CPSW'
workspaces = []
rooms = []
rows = plan.split('n')
rows.pop(0)
for no, row in enumerate(rows):
found = list(finditer(r'[^/\|+-]+', row))
if found:
rsegs = [RowSegment(no, rs.start(), rs.end()) for rs in found]
for ws in workspaces:
for rs in rsegs:
if max(ws.segs[-1].col_start, rs.col_start) < min(ws.segs[-1].col_end, rs.col_end): #add rs to ws
ws.segs.append(rs)
rsegs.remove(rs)
break
else: #no rs added => complete
text = ''.join([rows[s.row_no][s.col_start:s.col_end] for s in ws.segs])
name = search(r'(w+)', text)
if name:
ws.name = name[0][1:-1]
ws.furn_count = {f: text.count(f) for f in furn_types}
rooms.append(ws)
ws.complete = True
#reset ws list to only not complete
workspaces = [ws for ws in workspaces if ws.complete == False]
#create new wss with remaining rss
for rs in rsegs:
newws = Space()
newws.segs.append(rs)
workspaces.append(newws)
for r in rooms:
print(r)
The output (I’m also printing the "spaces" that compose each room) is:
bathroom: [('C', 1), ('P', 0), ('S', 1), ('W', 0)]
[1:17-36, 2:17-36, 3:17-36, 4:17-36, 5:17-36, 6:17-36]
office: [('C', 0), ('P', 0), ('S', 1), ('W', 1)]
[1:1-16, 2:1-16, 3:1-16, 4:1-16, 5:1-16, 6:1-16, 7:1-12, 8:1-11, 9:1-10, 10:1-9]
bedroom: [('C', 0), ('P', 0), ('S', 1), ('W', 2)]
[5:37-48, 6:37-48, 7:37-48, 8:26-48, 9:26-48, 10:26-48, 11:26-48, 12:26-48, 13:26-48]
kitchen: [('C', 0), ('P', 2), ('S', 3), ('W', 1)]
[8:12-25, 9:11-25, 10:10-25, 11:9-25, 12:10-25, 13:11-25]
hallway: [('C', 2), ('P', 1), ('S', 0), ('W', 1)]
[1:49-59, 2:49-59, 3:49-59, 4:49-59, 5:49-59, 6:49-59, 7:49-59, 8:49-59, 9:49-59, 10:49-59, 11:49-59, 12:49-59, 13:49-59, 14:49-59, 15:1-59, 16:1-59, 17:1-59, 18:44-59, 19:45-59, 20:46-59, 21:47-59, 22:48-59]
balcony: [('C', 1), ('P', 2), ('S', 1), ('W', 0)]
[19:16-29, 20:16-29, 21:16-29, 22:16-29, 23:16-29, 24:9-29, 25:9-29]
I would suggest something like:
-
consider the plan as a grid
-
replace all alphabetic letters (and parenthesis) with spaces
-
set as wall every cell with a non-space char
-
set the 1st non-wall cell as room 1
- loop over cells that are "air" near this one and set them as same room and loop over neightboring cells again and again untill you find walls
- when done, take a remaining cell and set it as room X
do again and again until all cells are attributed to different rooms
- compare the original plan (that have letters) with the given rooms you found
Assuming the below as I am inferring it from the (very short and unclear) problem statement (please inform if these are true). Answer is directional at best.
- ASCII plan is stored in txt file in local machine and needs to be read from software interface
- The plan will always be rectangular as the solution does not incorporate any other shape
- the ASCII chanrecters that represent the wall (| and / and and -) corners (+)
- Rooms will always be named in round brackets and will not spill outside room and will not be written overlapping a wall or outside the room
Heuristic
The search of a room and the room name is based on BFS algorithm which one can understand using this arcticle https://medium.com/geekculture/breadth-first-search-bfs-algorithm-with-python-952aea707e93
Python Solution
importing numpy and reading the file from the location on local machine. Maxlength is the length of the rectagular plan
floor_plan = [];
maxlength = 0;
Now we need to read the whole plan so as to have an object on which we will run the BFS
with open('txtfilewithASCIIplan.txt',encoding='utf-8-sig') as file:
for line in file.read().splitlines():
line_list = list(line)
if(len(line_list) > maxlength):
maxlength = len(line_list)
floor_plan.append(line_list)
Create the object now
for _,row in enumerate(floor_plan):
row.extend(list(' '*(maxlength - len(row))))
create the variables to be used in the BFS as described below. Comments are self explainatory
# seperator characters
seperator = {'+', '-', '|', '/', '\'}
#Number of Rows
number_of_rows = len(floor_plan)
#Number of Columns
number_of_columns = len(floor_plan[0])
#Matrix to keep track of points visited
visited = np.zeros(shape=(number_of_rows, number_of_columns), dtype=bool)
# contains a list of tuples; where the first element in tuple is room name and the second element is list of chairs in that room
room_with_chairs = []
# we store all the chairs present in the whole floor plan
list_of_all_letters = []
Do not visit a visited cell or a seperator
def shouldvisit(cell):
x = cell[0]
y = cell[1]
return not(visited[x][y] or (floor_plan[x][y] in seperator))
A sense check for seeing if we are in a diagonal corner which is essentially a Checks if both given cells contain separators
def are_both_separators(cell1, cell2):
return (floor_plan[cell1[0]][cell1[1]] in seperator) and (floor_plan[cell2[0]][cell2[1]] in seperator)
BFS needs to know its neighbours and we collect them here
# gets all the valid adjacent cells which can be at most 8
def get_neighbours(cell):
x = cell[0]
y = cell[1]
neighbours = []
# check for floor plan boundary
y_0 = y > 0 #check if current position is not along left-most verticle edge of floor plan
y_max = y < number_of_columns - 1 #check if current position is not along right-most verticle edge of floor plan
x_0 = x > 0 #check if current position is not along top-most horizontal edge of floor plan
x_max = x < number_of_rows - 1 #check if current position is not along bottom-most horizontal edge of floor plan
# Below is the check for valid neighbours
# The second condition is necessary because while checking a diagonal neighbour, we must make sure that
# the cells along the other diagonal do not contain separators as in that case the diagonal neighbour would be on the other side of the boundary
#check validity of i-1,j-1 & i-1,j+1
if(x_0):
if(y_0 and not are_both_separators((x-1, y), (x, y-1))):
neighbours.append((x-1, y-1))
neighbours.append((x-1, y))
if(y_max) and not are_both_separators((x-1, y), (x, y+1)):
neighbours.append((x-1, y+1))
#check validity of i,j-1
if(y_0):
neighbours.append((x, y-1))
#check validity of i,j+1
if(y_max):
neighbours.append((x, y+1))
##check validity of i+1,j-1 & i+1,j & i+1,j+1
if(x_max):
if(y_0 and not are_both_separators((x+1, y), (x, y-1))):
neighbours.append((x+1, y-1))
neighbours.append((x+1, y))
if(y_max and not are_both_separators((x+1, y), (x, y+1))):
neighbours.append((x+1, y+1))
return neighbours
Now we search for the name of the room
def get_room_name(cell):
x = cell[0]
y = cell[1]
row = ''.join(floor_plan[x]) #for string conversion
l=y;
r=y;
while(l>0 and row[l]!='('):
l-=1;
while(r<len(row)-1 and row[r]!=')'):
r+=1;
return row[l+1:r]
BFS
def bfs(cell):
queue = [cell]
chairs = []
area_name = None
visited[cell[0]][cell[1]] = True
while(queue != []):
curr_cell = queue.pop()
x = curr_cell[0]
y = curr_cell[1]
curr_char = floor_plan[x][y]
if(curr_char.isupper()):
chairs.append(curr_char)
elif((curr_char.islower() or curr_char == '(' or curr_char == ')') and area_name == None):
area_name = get_room_name(curr_cell)
for neighbour in get_neighbours(curr_cell):
if(shouldvisit(neighbour)):
visited[neighbour[0]][neighbour[1]] = True
queue.append(neighbour)
return (area_name, chairs)
Now running the BFS on the plan
# Doing BFS over every unvisited non-separator cell
for i, row in enumerate(floor_plan):
for j, _ in enumerate(row):
if(shouldvisit((i, j))):
local_outcome = bfs((i, j))
if(local_outcome[0] != None):
room_with_chairs.append(local_outcome)
list_of_all_letters.extend(local_outcome[1])
This completes the identification