diff --git a/requirements.txt b/requirements.txt index 68aebf6..6c7afac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ gdpc==7.1.0 +networkx==3.3 numpy==1.26.4 +Pillow==10.3.0 pygame==2.5.2 -scipy==1.13.0 +scipy==1.13.1 +skan==0.11.1 +skimage==0.0 diff --git a/world_maker/City.py b/world_maker/City.py index 1e8574f..fa0e581 100644 --- a/world_maker/City.py +++ b/world_maker/City.py @@ -1,4 +1,4 @@ -from District import District, CustomDistrict, VoronoiDistrict +from District import District from Position import Position from PIL import Image import random @@ -33,13 +33,14 @@ class City: watermap.close() heightmap.close() - def add_district(self, center: Position): + def add_district(self, center: Position, district_type: str = ""): """ Add a new district to the city. + :param district_type: :param center: The center position of the new district. """ - self.districts.append(CustomDistrict(len(self.districts) + 1, center)) + self.districts.append(District(len(self.districts) + 1, center, district_type)) self.map_data[center.y][center.x] = len(self.districts) def is_expend_finished(self): @@ -121,6 +122,6 @@ class City: if __name__ == '__main__': city = City() for i in range(10): - city.add_district(Position(random.randint(0, 600), random.randint(0, 600))) + city.add_district(Position(random.randint(0, 400), random.randint(0, 400))) city.loop_expend_district() city.custom_district_draw_map() diff --git a/world_maker/District.py b/world_maker/District.py index da218d1..5ee0888 100644 --- a/world_maker/District.py +++ b/world_maker/District.py @@ -1,45 +1,6 @@ from Position import Position - class District: - """ - The District class represents a district in the world. - A district can be characterized by its type and its unique id. - - Attributes: - tile_id (int): The unique id of the district. - type (str): The type of the district. Can be "Forest", "City", "Mountain" or "Villa". - """ - - def __init__(self, tile_id: int): - """ - The constructor for the District class. - - :param tile_id: Unique id of the district (Must be greater than 0) - """ - if tile_id <= 0: - raise ValueError("Tile id must be greater than 0") - self.tile_id = tile_id - self.type = "" #Forest, City, Montain, Villa - - -def verify_point(point: Position, point_new: Position, map_data: list[list[int]], height_map: list[list[int]]): - """ - Function to verify if a new point can be added to a district extend area list. - - :param point: The current point. - :param point_new: The new point to be verified. - :param map_data: The 2D list representing the map. - :param height_map: The 2D list representing the height map. - :return: True if the new point can be added, False otherwise. - """ - return (0 <= point_new.x < len(map_data[0]) and - 0 <= point_new.y < len(map_data) and - map_data[point_new.y][point_new.x] == 0 and - abs(height_map[point_new.y][point_new.x] - height_map[point.y][point.x]) < 2) - - -class CustomDistrict(District): """ The CustomDistrict class represents a district that can be expanded. @@ -49,19 +10,39 @@ class CustomDistrict(District): area_expend_from_point (list): The list of positions from which the district can expand. area_expend (list): The list of positions to which the district will maybe expand. """ - def __init__(self, tile_id: int, center: Position): + def __init__(self, tile_id: int, center: Position, type: str = ""): """ - The constructor for the CustomDistrict class. + The constructor for the District class. :param tile_id: Unique id of the district (Must be greater than 0) :param center: The center position from which the district expands. + :param type: The type of the district (Forest, City, Mountain, Villa) """ - super().__init__(tile_id) + if tile_id <= 0: + raise ValueError("Tile id must be greater than 0") + self.tile_id = tile_id + self.type = type self.center_expend = center self.area = [center] self.area_expend_from_point = [center] self.area_expend = [] + def verify_point(self, point: Position, point_new: Position, map_data: list[list[int]], height_map: list[list[int]]): + """ + Verify if a new point can be added to a district extend area list. + + :param point: The current point. + :param point_new: The new point to be verified. + :param map_data: The 2D list representing the map. + :param height_map: The 2D list representing the height map. + :return: True if the new point can be added, False otherwise. + """ + return (0 <= point_new.x < len(map_data[0]) and + 0 <= point_new.y < len(map_data) and + map_data[point_new.y][point_new.x] == 0 and + (self.type == "Mountain" or + abs(height_map[point_new.y][point_new.x] - height_map[point.y][point.x]) < 2)) + def update_expend_points(self, point: Position, map_data: list[list[int]], height_map: list[list[int]]): """ Update the points to which the district can expand. @@ -71,20 +52,7 @@ class CustomDistrict(District): :param height_map: The 2D list representing the height map. """ for pos in [Position(1, 0), Position(-1, 0), Position(0, 1), Position(0, -1)]: - if verify_point(point, point + pos, map_data, height_map): + if self.verify_point(point, point + pos, map_data, height_map): if point + pos not in self.area_expend: self.area_expend.append(point + pos) self.area_expend_from_point.remove(point) - - -class Edge: #I'm Edging rn - def __init__(self, point1, point2): - self.point1 = point1 - self.point2 = point2 - - -class VoronoiDistrict(District): - def __init__(self, tile_id: int, center: Position): - super().__init__(tile_id) - self.center = center - self.edges = [] diff --git a/world_maker/Skeleton.py b/world_maker/Skeleton.py new file mode 100644 index 0000000..574d856 --- /dev/null +++ b/world_maker/Skeleton.py @@ -0,0 +1,205 @@ +import numpy as np +import skan +from skimage.morphology import skeletonize +from skan.csr import skeleton_to_csgraph +from collections import Counter +from PIL import Image +import random + +from gdpc import Editor + + +class Skeleton: + def __init__(self): + self.lines = [] + self.intersections = [] + self.centers = [] + self.graph = [] + self.coordinates = [] + + def setSkeleton(self, data): + binary_skeleton = skeletonize(data) + + graph, coordinates = skeleton_to_csgraph(binary_skeleton) + self.graph = graph.tocoo() + + # List of lists. Inverted coordinates. + coordinates = list(coordinates) + print(coordinates) + for i in range(len(coordinates)): + coordinates[i] = list(coordinates[i]) + + print(coordinates) + coordinates_final = [] + + for i in range(len(coordinates[0])): + print((coordinates[0][i], coordinates[1][i], coordinates[2][i])) + coordinates_final.append((coordinates[0][i], coordinates[1][i], coordinates[2][i])) + + self.coordinates = coordinates_final + + def findNextElements(self, key): + """Find the very nearest elements""" + + line = [] + + values = np.array(self.graph.row) + indices = np.where(values == key)[0] + + for i in range(len(indices)): + if self.graph.row[indices[i]] == key: + line.append(self.graph.col[indices[i]]) + return line + + def findLine(self, key): + nextKeys = self.findNextElements(key) + + if len(nextKeys) >= 3: # Intersections. + return nextKeys + + if len(nextKeys) == 2 or len(nextKeys) == 1: # In line or endpoints. + line = [] + line.append(key) + line.insert(0, nextKeys[0]) + if len(nextKeys) == 2: + line.insert(len(line), nextKeys[1]) + + nextKeys = line[0], line[-1] + + while len(nextKeys) == 2 or len(nextKeys) == 1: + extremity = [] + for key in nextKeys: + nextKeys = self.findNextElements(key) + + if len(nextKeys) <= 2: + # Add the neighbors that is not already in the line. + for element in nextKeys: + if element not in line: + extremity.append(element) + line.append(element) + + if len(nextKeys) >= 3: + # Add the intersection only. + extremity.append(key) + + nextKeys = [] + for key in extremity: + ends = self.findNextElements(key) + if len(ends) == 2: + nextKeys.append(key) + return line + + def parseGraph(self): + for key, value in sorted( + Counter(self.graph.row).items(), key=lambda kv: kv[1], reverse=True + ): + # Start from the biggest intersections. + if value != 2: # We don't want to be in the middle of a line. + line = self.findLine(key) + + # We have now all the connected points if it's an + # intersection. We need to find the line. + + if value != 1: + # It's not an endpoint. + self.centers.append(key) + self.intersections.append(line) + for i in line: + line = self.findLine(i) + + if i in line: + # The key is inside the result : it's a line. + alreadyInside = False + for l in self.lines: + # Verification if not already inside. + if Counter(l) == Counter(line): + alreadyInside = True + # print(line, "inside", lines) + + if alreadyInside == False: + self.lines.append(line) + else: + # The key is not inside the result, it's an + # intersection directly connected to the key. + line = [key, i] + alreadyInside = False + for l in self.lines: + # Verification if not already inside. + if Counter(l) == Counter(line): + alreadyInside = True + # print(line, "inside", lines) + + if alreadyInside == False: + self.lines.append(line) + + def map(self): + """ + + Generate an image to visualize 2D path of the skeleton. + + Returns: + image: 2D path of the skeleton on top of the heightmap. + """ + editor = Editor() + + buildArea = editor.getBuildArea() + buildRect = buildArea.toRect() + xzStart = buildRect.begin + xzDistance = (max(buildRect.end[0], buildRect.begin[0]) - min(buildRect.end[0], buildRect.begin[0]), + max(buildRect.end[1], buildRect.begin[1]) - min(buildRect.end[1], buildRect.begin[1])) + + heightmap = Image.open("data/heightmap.png").convert('RGB') + roadsArea = Image.new("L", xzDistance, 0) + width, height = heightmap.size + + # Lines + for i in range(len(self.lines)): + r, g, b = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + + for j in range(len(self.lines[i])): + z = self.coordinates[self.lines[i][j]][0] + y = self.coordinates[self.lines[i][j]][1] + x = self.coordinates[self.lines[i][j]][2] + + heightmap.putpixel( + ( + int(z), + int(x), + ), + (r + j, g + j, b + j), + ) + + roadsArea.putpixel( + ( + int(z), + int(x), + ), + (255), + ) + + # Centers + for i in range(len(self.centers)): + print(self.coordinates[self.centers[i]]) + heightmap.putpixel( + (int(self.coordinates[self.centers[i]][0]), int(self.coordinates[self.centers[i]][2])), + (255, 255, 0), + ) + + roadsArea.putpixel( + (int(self.coordinates[self.centers[i]][0]), int(self.coordinates[self.centers[i]][2])), + (255), + ) + + # # Intersections + # for i in range(len(self.intersections)): + # intersection = [] + # for j in range(len(self.intersections[i])): + # intersection.append(self.coordinates[self.intersections[i][j]]) + + # for i in range(len(intersection)): + # heightmap.putpixel( + # (int(self.intersections[i][2]), int(self.intersections[i][0])), + # (255, 0, 255), + # ) + + return heightmap, roadsArea \ No newline at end of file diff --git a/world_maker/data/custom_district.png b/world_maker/data/custom_district.png index b9b8b3c..54bdf7f 100644 Binary files a/world_maker/data/custom_district.png and b/world_maker/data/custom_district.png differ diff --git a/world_maker/data/heightmap.png b/world_maker/data/heightmap.png index e408aea..0f41f54 100644 Binary files a/world_maker/data/heightmap.png and b/world_maker/data/heightmap.png differ diff --git a/world_maker/data/highwaymap.png b/world_maker/data/highwaymap.png new file mode 100644 index 0000000..84ba4bb Binary files /dev/null and b/world_maker/data/highwaymap.png differ diff --git a/world_maker/data/sobelmap.png b/world_maker/data/sobelmap.png new file mode 100644 index 0000000..bab6916 Binary files /dev/null and b/world_maker/data/sobelmap.png differ diff --git a/world_maker/data/test.png b/world_maker/data/test.png new file mode 100644 index 0000000..750776d Binary files /dev/null and b/world_maker/data/test.png differ diff --git a/world_maker/data/treemap.png b/world_maker/data/treemap.png index a8afb9f..acf6614 100644 Binary files a/world_maker/data/treemap.png and b/world_maker/data/treemap.png differ diff --git a/world_maker/data/watermap.png b/world_maker/data/watermap.png index 05c37e8..4f74618 100644 Binary files a/world_maker/data/watermap.png and b/world_maker/data/watermap.png differ diff --git a/world_maker/data_analysis.py b/world_maker/data_analysis.py new file mode 100644 index 0000000..4c55ff3 --- /dev/null +++ b/world_maker/data_analysis.py @@ -0,0 +1,182 @@ +import World +from PIL import Image +from PIL import ImageFilter +import numpy as np +import networkx as nx +from scipy import ndimage +from scipy.ndimage import gaussian_gradient_magnitude +from scipy.ndimage import label +from Skeleton import Skeleton + +def get_data(world: World): + heightmap, watermap, treemap = world.getData() + heightmap.save('./data/heightmap.png') + watermap.save('./data/watermap.png') + treemap.save('./data/treemap.png') + return heightmap, watermap, treemap + + +def filter_inverse(image: Image) -> Image: + """ + Invert the colors of an image. + + Args: + image (image): image to filter + """ + return Image.fromarray(np.invert(np.array(image))) + + +def filter_sobel(image) -> Image: + """ + Edge detection algorithms from an image. + + Args: + image (image): image to filter + """ + + # Open the image + if isinstance(image, str): + image = Image.open(image).convert('RGB') + + img = np.array(image).astype(np.uint8) + + # Apply gray scale + gray_img = np.round( + 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2] + ).astype(np.uint8) + + # Sobel Operator + h, w = gray_img.shape + # define filters + horizontal = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]) # s2 + vertical = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]]) # s1 + + # define images with 0s + newhorizontalImage = np.zeros((h, w)) + newverticalImage = np.zeros((h, w)) + newgradientImage = np.zeros((h, w)) + + # offset by 1 + for i in range(1, h - 1): + for j in range(1, w - 1): + horizontalGrad = ( + (horizontal[0, 0] * gray_img[i - 1, j - 1]) + + (horizontal[0, 1] * gray_img[i - 1, j]) + + (horizontal[0, 2] * gray_img[i - 1, j + 1]) + + (horizontal[1, 0] * gray_img[i, j - 1]) + + (horizontal[1, 1] * gray_img[i, j]) + + (horizontal[1, 2] * gray_img[i, j + 1]) + + (horizontal[2, 0] * gray_img[i + 1, j - 1]) + + (horizontal[2, 1] * gray_img[i + 1, j]) + + (horizontal[2, 2] * gray_img[i + 1, j + 1]) + ) + + newhorizontalImage[i - 1, j - 1] = abs(horizontalGrad) + + verticalGrad = ( + (vertical[0, 0] * gray_img[i - 1, j - 1]) + + (vertical[0, 1] * gray_img[i - 1, j]) + + (vertical[0, 2] * gray_img[i - 1, j + 1]) + + (vertical[1, 0] * gray_img[i, j - 1]) + + (vertical[1, 1] * gray_img[i, j]) + + (vertical[1, 2] * gray_img[i, j + 1]) + + (vertical[2, 0] * gray_img[i + 1, j - 1]) + + (vertical[2, 1] * gray_img[i + 1, j]) + + (vertical[2, 2] * gray_img[i + 1, j + 1]) + ) + + newverticalImage[i - 1, j - 1] = abs(verticalGrad) + + # Edge Magnitude + mag = np.sqrt(pow(horizontalGrad, 2.0) + pow(verticalGrad, 2.0)) + newgradientImage[i - 1, j - 1] = mag + + image = Image.fromarray(newgradientImage) + image = image.convert("L") + + return image + + +def filter_smooth(image, radius: int = 3): + """ + :param image: white and black image representing the derivative of the terrain (sobel), where black is flat and white is very steep. + :param radius: Radius of the Gaussian blur. + + Returns: + image: black or white image, with black as flat areas to be skeletonized + """ + + if isinstance(image, str): + image = Image.open(image) + + # image = image.filter(ImageFilter.SMOOTH_MORE) + # image = image.filter(ImageFilter.SMOOTH_MORE) + # image = image.filter(ImageFilter.SMOOTH_MORE) + image = image.convert('L') + image = image.filter(ImageFilter.GaussianBlur(radius)) + array = np.array(image) + + bool_array = array > 7 + + # bool_array = ndimage.binary_opening(bool_array, structure=np.ones((3,3)), iterations=1) + # bool_array = ndimage.binary_closing(bool_array, structure=np.ones((3,3)), iterations=1) + # bool_array = ndimage.binary_opening(bool_array, structure=np.ones((5,5)), iterations=1) + # bool_array = ndimage.binary_closing(bool_array, structure=np.ones((5,5)), iterations=1) + # bool_array = ndimage.binary_opening(bool_array, structure=np.ones((7,7)), iterations=1) + # bool_array = ndimage.binary_closing(bool_array, structure=np.ones((7,7)), iterations=1) + + return Image.fromarray(bool_array) + + +def remove_water_from_map(image: Image) -> Image: + watermap = Image.open('./data/watermap.png').convert('L') + + array_heightmap = np.array(image) + array_watermap = np.array(watermap) + + mask = array_watermap == 255 + array_heightmap[mask] = 0 + + result_image = Image.fromarray(array_heightmap) + return result_image + + +def group_map(image1: Image, image2: Image) -> Image: + array1 = np.array(image1) + array2 = np.array(image2) + + mask = array1 == 255 + array2[mask] = 255 + + result_image = Image.fromarray(array2) + return result_image + + +def highway_map() -> Image: + smooth_sobel = filter_smooth("./data/sobelmap.png", 1) + inverse_sobel = filter_inverse(smooth_sobel) + sobel_no_water = remove_water_from_map(inverse_sobel) + sobel_no_water.save("./data/test.png") + array = np.array(sobel_no_water) + array = ndimage.binary_erosion(array, iterations=10) + array = ndimage.binary_dilation(array, iterations=5) + image = Image.fromarray(array) + smooth_image = filter_smooth(image, 5) + array = np.array(smooth_image) + array = ndimage.binary_erosion(array, iterations=17) + image = Image.fromarray(array) + smooth_image = filter_smooth(image, 6) + array = np.array(smooth_image) + array = ndimage.binary_dilation(array, iterations=3) + image = Image.fromarray(array) + image.save('./data/highwaymap.png') + return image + +def skeletonnize_map(map: Image): + skeleton = Skeleton() + image_array = np.array(map) + skeleton.setSkeleton(image_array) + skeleton.parseGraph() + heightmap_skeleton, roadsArea = skeleton.map() + heightmap_skeleton.save('./data/skeleton.png') + roadsArea.save('./data/roads.png') \ No newline at end of file diff --git a/world_maker/world_maker.py b/world_maker/world_maker.py index 1ff126f..ac8b3db 100644 --- a/world_maker/world_maker.py +++ b/world_maker/world_maker.py @@ -1,17 +1,10 @@ import World - - -def get_data(world: World): - heightmap, watermap, treemap = world.getData() - heightmap.save('./data/heightmap.png') - watermap.save('./data/watermap.png') - treemap.save('./data/treemap.png') - - -def main(): - world = World.World() - get_data(world) - +from PIL import Image +from data_analysis import get_data, highway_map, filter_sobel, skeletonnize_map if __name__ == '__main__': - main() + #world = World.World() + #heightmap, watermap, treemap = get_data(world) + #filter_sobel("./data/heightmap.png").save('./data/sobelmap.png') + highway_map() + skeletonnize_map(Image.open('./data/highwaymap.png'))