Author Topic: Python script for displaying maps  (Read 2961 times)

Offline pente

  • Newbie
  • *
  • Posts: 38
Python script for displaying maps
« on: April 14, 2019, 05:41:33 AM »
Somewhat inevitably I went ahead and wrote a python script for displaying a given .map file as a png. It doesn't really do anything that the OP2 Map Imager doesn't but I may as well share what I did anyhow. I got stuck for a bit on a problem with .map files coming in weird chunks of 32 at a time which I ended up having to check the Map Imager source to figure out. The script assumes the tileset files have been converted to png format and requires the imageio python library for image reading/writing (which failed to read the .bmp file). It displays 1, 2, 4, 8, 16, or 32 pixels per tile.

Code below and example output at the end:

Code: [Select]
import sys
import struct
import os
import os.path
import numpy as np

import imageio

# The tileset files well0000.png etc. must exist in *png* format
# in the following directory.
# sys.path[0] gives the directory that contains the python script.
tileset_dir = os.path.join(sys.path[0], '..', 'data', 'extracted_art.vol')

si4 = struct.Struct('<i') # 4 byte integer, little endian
si2 = struct.Struct('<h') # 2 byte integer, little endian

class Map:
    def __init__(self, data):
        self.data = data
        self.index = 0
        self.N = len(data)

        self.read_all()

    # static
    def read_file(filename):
        with open(filename, 'rb') as stream:
            data = stream.read()
        return Map(data)

    ## Read a .map file ##

    def read_all(self):
        self.read_header()
        self.read_map()
        self.read_clip_region()
        self.read_tileset_names()
        self.read_tiles()

    ## Helper functions for reading a .map file ##

    def next_k(self, k):
        b = self.data[self.index : self.index + k]
        self.index += k
        assert self.index <= self.N
        return b

    def read_int4(self):
        int4, = si4.unpack(self.next_k(4))
        return int4

    def read_int2(self):
        int2, = si2.unpack(self.next_k(2))
        return int2

    def read_byte(self):
        return self.next_k(1)[0]

    def read_header(self):
        assert self.read_int4() >= 0x1010
        self.read_int4()
        self.width = 2 ** self.read_int4()
        self.height = self.read_int4()
        self.num_tilesets = self.read_int4()

    def read_map(self):
        start = self.index
        end = self.index + 4 * self.width * self.height
        assert end <= self.N

        raw_map = np.ndarray(shape = (self.width * self.height,),
                dtype = '<u4', # little endian
                buffer = self.data[start : end])

        self.map = np.zeros((self.width, self.height), dtype = raw_map.dtype)
        for i in range(0, self.width, 32):
            col = raw_map[i * self.height : (i + 32) * self.height]
            self.map[i : i + 32, :] = col.reshape((32, self.height), order = 'F')

        self.map_tile_index = (self.map >> 5) & ((2 ** 11) - 1)
        self.index = end

    def read_clip_region(self):
        self.read_int4()
        self.read_int4()
        self.read_int4()
        self.read_int4()

    def read_tileset_names(self):
        self.tileset_filenames = [None] * self.num_tilesets
        self.tileset_lengths = [0] * self.num_tilesets
        self.num_valid_tilesets = 0
        for i in range(self.num_tilesets):
            k = self.read_int4()
            if k > 0:
                filename_base = self.next_k(8).decode('ascii')
                self.tileset_filenames[i] = filename_base + '.png'
                self.tileset_lengths[i] = self.read_int4()
                self.num_valid_tilesets += 1

        for i in range(self.num_valid_tilesets):
            assert self.tileset_lengths[i] > 0
        self.tileset_filenames = self.tileset_filenames[:self.num_valid_tilesets]
        self.tileset_lengths = self.tileset_lengths[:self.num_valid_tilesets]

    def read_tiles(self):
        assert self.next_k(10) == b'TILE SET\x1a\x00'
        self.num_tiles = self.read_int4()
        self.tiles = [None] * self.num_tiles
        for i in range(self.num_tiles):
            tileset = self.read_int2()
            tileindex = self.read_int2()
            self.next_k(4) # animation data
            self.tiles[i] = (tileset, tileindex)

    def create_image(self, z):
        assert z in [1, 2, 4, 8, 16, 32]

        t = Tilesets(self)
        palette = t.prepare_palette(z)

        image = np.zeros((self.height * z, self.width * z, 4), dtype = t.dtype)
        for j in range(self.height):
            for a in range(z):
                image[j * z + a] = palette[a, self.map_tile_index[:, j], :, :].reshape((self.width * z, 4), order = 'C')

        return image

class Tilesets:
    def __init__(self, m):
        self.num_sets = m.num_valid_tilesets
        self.filenames = list(m.tileset_filenames)
        self.lengths = list(m.tileset_lengths)

        self.n = m.num_tiles
        self.tiles = list(m.tiles)

        self.read_tilesets()

    def read_tilesets(self):
        self.tilesets = [None] * self.num_sets
        for i in range(self.num_sets):
            k = self.lengths[i]
            data = imageio.imread(os.path.join(tileset_dir, self.filenames[i]))
            assert data.shape[:2] == (32 * k, 32)

            if len(data.shape) == 2:
                data = np.copy(np.broadcast_to(data[:, :, None], (32 * k, 32, 4)))

            self.tilesets[i] = data
            self.dtype = data.dtype

    def prepare_palette(self, z = 8):
        zoomed_tilesets = []
        for i in range(self.num_sets):
            zoomed_tilesets.append(zoom_image(self.tilesets[i], z))

        palette = np.zeros((z, self.n, z, 4), dtype = self.dtype)
        for i in range(self.n):
            w, idx = self.tiles[i]
            t = zoomed_tilesets[w]
            palette[:, i, :, :] = t[z * idx : z * (idx + 1), :, :]

        if False:
            palette_flat = np.zeros((z, self.n * z, 4), dtype = self.dtype)
            for i in range(z):
                palette_flat[i] = palette[i].reshape((self.n * z, 4), order = 'C')
            imageio.imwrite('palette.png', palette_flat)

        return palette

def zoom_image(data, z):
    if z == 32:
        return data

    assert z in [1, 2, 4, 8, 16, 32]
    r = 32 // z

    data1_ = np.cumsum(data, axis = 0)[r - 1 :: r]
    data1 = np.copy(data1_)
    data1[1:] -= data1_[:-1]
    data2_ = np.cumsum(data1, axis = 1)[:, r - 1 :: r]
    data2 = np.copy(data2_)
    data2[:, 1:] -= data2_[:, :-1]

    return data2

def run(pixels_per_tile, filename):
    assert os.path.isfile(filename)

    m = Map.read_file(filename)
    print(m.width, m.height)

    image = m.create_image(pixels_per_tile)
    imageio.imwrite('map.png', image)

if __name__ == "__main__":
    pixels_per_tile = int(sys.argv[1])
    filename = sys.argv[2]
    run(pixels_per_tile, filename)


Offline Hooman

  • Administrator
  • Hero Member
  • *****
  • Posts: 4955
Re: Python script for displaying maps
« Reply #1 on: April 15, 2019, 01:41:02 AM »
Huh, good job. That looks to be reasonably compact code.

I believe the chunking is to reduce the number of pages of memory that have to be touched when drawing a section of the map. For really wide maps, but a not so big screen size, a straight 2D array might need to touch twice as many pages of memory as rows of tiles. Basically, every row of tile would be on a new page, and might straddle a page boundary. With the chunking, it should be limited to 4 pages of memory in the worst case, and that's if it straddles a page boundary in both x and y. Back when Outpost 2 was released, computers were much more memory constrained, and Outpost 2 allowed for fairly large maps. The chunking was probably useful to help keep the memory manager from thrashing.