ai-lab-one/read_in.py

225 lines
7.3 KiB
Python

from emis_funky_funktions import *
from world import Terrain, World
from PIL import Image
from itertools import chain
from typing import List, NamedTuple, Tuple
def meters_to_millimeters(meters: float) -> int:
"""
Convert meters (floating point) to millimeters (int)
>>> meters_to_millimeters(1.9333473e+02)
193334
>>> meters_to_millimeters(1.0)
1000
"""
return int(1000 * meters)
def read_single_elevation(dat: str) -> Result[int, str]:
"""
Read in a single elevation
Expects a single, stringified floating point value, in meters, without a unit, e.g.
"2.057e+02". If there is a parsing error, returns an error containing the input
string. Otherwise, returns the elevation in millimeters.
>>> read_single_elevation("1.9333473e+02")
Ok(193334)
>>> read_single_elevation("1.0")
Ok(1000)
>>> read_single_elevation("10.0m")
Err('10.0m')
"""
return map_res(
meters_to_millimeters, # fuck floating points
try_(replace(dat), float, dat)
)
def read_elevation_line(line: str) -> Result[Iterator[int], Tuple[int, str]]:
"""
Reads a line of elevations
Each elevation should be in the format specified by `read_single_elevation`. Every
element of the line is parsed and combined into an iterator, which is returned. If
any parsing errors are encountered, the first error is returned. It takes the form of
a tuple containing the column of the error (in terms of whitespace-deliniated words,
not characters) and the string which could not be parsed.
Recall from the definition of `read_single_elevation()` that the return value is an
integer number of millimeters.
As per the problem specification, the last five columns of the line are not read or
parsed. Parsing errors in these columns therefor cannot be encountered, and any data
in these columns will not be returned.
>>> map_res(list, read_elevation_line("1.9e2 18e1 181 179.1 ignored 170.1 1.8e2 18i+2 20e1"))
Ok([190000, 180000, 181000, 179100])
>>> map_res(list, read_elevation_line("1.9e2 18e 181 179.1 ignored 170.1 1.8e2 18i+2 20e1"))
Err((2, '18e'))
"""
return sequence([
map_err(
lambda err_text: (col_no + 1, err_text),
read_single_elevation(elem)
)
# As per spec, we drop the last five entries of the file.
for (col_no, elem) in enumerate(line.split()[:-5])
])
class UnparsableElevation(NamedTuple):
"An elevation entry failed to be parsed as a floating point"
line_no: int
"The line on which the error occurred"
col_no: int
"The column (1-indexed number of whitespace deliniated words) on which the error occurred"
invalid_entry: str
"The text which failed to parse as a floating point value"
def read_elevations(lines: Iterable[str]) -> Result[Iterator[int], UnparsableElevation]:
"""
Read an entire elevation file into a list
This parses each line in of the file using `read_elevation_line()`. Errors
encountered are reported using an `UnparsableElevation`. The resulting list is
single-dimensional, but can be indexed arbitrarily, meaning for an input file with a
constant number of columns per line, this data can be accessed as if it were a 2d
square array.
However, keep in mind that, as with `read_elevation_line()`, the last five columns of
each line are dropped/ignored, and are therefor not present in the returned data.
>>> map_res(list, read_elevations('''
... 1 2 3 4 5 6 7 8 9e 10
... 11 2.0 3e1 4 5 6 7 bwa 9 10
... 11 2.0 3e0 4 5 ign err 8 9 10
... '''.strip().split('\\n')))
Ok([1000, 2000, 3000, 4000, 5000, 11000, 2000, 30000, 4000, 5000, 11000, 2000, 3000, 4000, 5000])
>>> map_res(list, read_elevations('''
... 1 2 3 4 5 6 7 8 9e 10
... 11 ERR 3e1 4 5 6 7 bwa 9 10
... 11 2.0 3e0 4 5 ign err 8 9 10
... '''.strip().split('\\n')))
Err(UnparsableElevation(line_no=2, col_no=2, invalid_entry='ERR'))
"""
return map_res(
chain.from_iterable,
sequence([
map_err(
lambda err: UnparsableElevation(line_no + 1, *err),
read_elevation_line(line)
)
for (line_no, line) in enumerate(lines)
])
)
def gb_to_terrain_type(green_blue: Tuple[int, int]) -> Option[Terrain]:
"""
Maps the green and blue components of a color to a terrain type.
If no terrain type exists with that color, then `None` is returned.
>>> gb_to_terrain_type((192, 0))
Some(<Terrain.ROUGH_MEADOW: 700>)
>>> gb_to_terrain_type((192, 1)) is None
True
"""
match green_blue:
case (148, 18):
return Some(Terrain.OPEN_LAND)
case (192, 0):
return Some(Terrain.ROUGH_MEADOW)
case (255, 255):
return Some(Terrain.EASY_FOREST)
case (208, 60):
return Some(Terrain.MEDIUM_FOREST)
case (136, 40):
return Some(Terrain.WALK_FOREST)
case (73, 24):
return Some(Terrain.BRUSH)
case (0, 255):
return Some(Terrain.WET)
case (51, 3):
return Some(Terrain.ROAD)
case (0, 0):
return Some(Terrain.FOOTPATH)
case (0, 101):
return Some(Terrain.OOB)
case _:
return None
class UnrecognizedColor(NamedTuple):
"A color in a provided map was not in the list of recognized colors"
x: int
"The x coordinate of the unidentifided color"
y: int
"The y coordinate of the unidentifided color"
def load_gb_pixels_as_map(pixels: Iterable[Tuple[int, int]]) -> Result[Iterator[Terrain], UnrecognizedColor]:
"""
Interpret the green and blue bands of an image as pixels on a map
This assumes that the map follows the specification laid out at
https://cs.rit.edu/~jro/courses/intelSys/labs/orienteering/
Namely, the map should be exactly 395 pixels wide, and should follow the color
conventions laid out in the Terrain Type table.
This function is only interested in the green and blue channels of the image. Rather
than pass a full pixel, it is only necessary to pass the green and blue color values.
If an error is encountered, the returned `Err` type will contain the x and y
coordinates of the pixel which could not be identified.
"""
return sequence([
note(
UnrecognizedColor(pix_no % 395, pix_no // 395),
gb_to_terrain_type(gb)
)
for (pix_no, gb) in enumerate(pixels)
])
def load_image(path: str) -> Result[List[Terrain], UnrecognizedColor]:
"Run `load_gb_pixels_as_map` on an image read from a path"
with Image.open(path) as im:
return load_gb_pixels_as_map(zip(im.getdata(1), im.getdata(2)))
def load_elevations(path: str) -> Result[Iterator[int], UnparsableElevation]:
"Run `read_elevations` on a text file read from a path"
with open(path) as file:
return read_elevations(file)
def load_world_from_paths(map_path: str, elevation_path: str) -> Result[World, UnparsableElevation | UnrecognizedColor]:
"""
Read world information (terrain and elevation) from file paths.
See `read_elevations` and `load_gb_pixels_as_map` for more information on the format
that this data should be in. Note that for this method, you do not need to worry
about the channels present in the terrain map, just that it align with the colors and
size of the specified format.
"""
return bind_res(
lambda terrain_data:
map_res(
lambda elevation_data: World((*zip(terrain_data, elevation_data),)),
load_elevations(elevation_path)
),
load_image(map_path)
)
if __name__ == '__main__':
import doctest
doctest.testmod()