Skip to content

Instantly share code, notes, and snippets.

@lukad
Created December 30, 2023 14:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lukad/b975316dedf1657d69e878374ce75d04 to your computer and use it in GitHub Desktop.
Save lukad/b975316dedf1657d69e878374ce75d04 to your computer and use it in GitHub Desktop.

Image to gameboy tile conversion

Mix.install([
  {:ex_png, "~> 1.0"},
  {:kino, "~> 0.11.2"},
  {:nx, "~> 0.6.3"}
])

Constants

width = 160
height = 140

tile_size = 8
tiles_per_row = div(width, tile_size)

Convert image to 2 bit grayscale

file_input = Kino.Input.image("Image", format: :rgb, size: {height, width}, fit: :crop)
image = Kino.Input.read(file_input)
image_path = Kino.Input.file_path(image.file_ref)
image_data = File.read!(image_path)
rgb =
  image_data
  |> :binary.bin_to_list()
  |> Enum.chunk_every(3)

grayscale =
  rgb
  |> Enum.map(fn [r, g, b] -> div(r + g + b, 3) end)

{min_value, max_value} = Enum.min_max(grayscale)
Enum.min_max(grayscale)

Dithering

quantize = fn value ->
  trunc(value / 255.0 * 3)
end
clamp = fn value, lower, upper ->
  max(min(value, upper), lower)
end
update_row = fn rows, y, x, new_value ->
  List.update_at(rows, y, fn row -> List.update_at(row, x, fn _ -> new_value end) end)
end
diffuse_error = fn rows, error, x, y ->
  factors = [{1, 0, 7 / 16}, {0, 1, 5 / 16}, {-1, 1, 3 / 16}, {1, 1, 1 / 16}]

  Enum.reduce(factors, rows, fn {dx, dy, factor}, acc_rows ->
    new_x = x + dx
    new_y = y + dy

    if new_y < length(acc_rows) and new_x >= 0 and new_x < length(Enum.at(acc_rows, new_y)) do
      old_value = Enum.at(Enum.at(acc_rows, new_y), new_x)
      new_value = clamp.(old_value + round(error * factor), 0, 255)
      update_row.(acc_rows, new_y, new_x, new_value)
    else
      acc_rows
    end
  end)
end
diffuse_pixel = fn value, rows, x, y ->
  new_pixel = quantize.(value)
  error = value - new_pixel * 255 / 3.0
  updated_rows = diffuse_error.(rows, error, x, y)
  {new_pixel, updated_rows}
end
apply_dithering = fn rows ->
  Enum.reduce(0..(length(rows) - 1), rows, fn y, acc_rows ->
    Enum.reduce(0..(length(Enum.at(rows, y)) - 1), acc_rows, fn x, acc_inner_rows ->
      {new_pixel, updated_rows} =
        diffuse_pixel.(Enum.at(Enum.at(acc_inner_rows, y), x), acc_inner_rows, x, y)

      update_row.(updated_rows, y, x, new_pixel)
    end)
  end)
end
offsets = Nx.tensor([[1, 0], [-1, 1], [0, 1], [1, 1]])
factors = Nx.tensor([7, 3, 5, 1]) |> Nx.divide(16)
# offsets = Nx.tensor([[1, 0], [-1, 1], [0, 1], [1, 1]])
offsets = [{1, 0}, {-1, 1}, {0, 1}, {1, 1}]
# factors = Nx.tensor([7, 3, 5, 1]) |> Nx.divide(16)
# |> Enum.map(&(&1 / 16))
factors = [7.0 / 16.0, 3.0 / 16.0, 5.0 / 16.0, 1.0 / 16.0]

dither = fn data, w, h ->
  data =
    for y <- 0..(h - 1), x <- 0..(w - 1), into: %{} do
      {{x, y}, Enum.at(data, y * w + x)}
    end

  data =
    for y <- 0..(h - 1), x <- 0..(w - 1), reduce: data do
      data ->
        old = Map.get(data, {x, y})
        new = (old / 255.0) |> round()
        err = (old - new) |> round()
        data = Map.put(data, {x, y}, new)

        offsets
        |> Enum.map(fn {dx, dy} -> {x + dx, y + dy} end)
        |> Enum.zip(factors)
        |> Enum.filter(fn {{x, y}, _factor} -> x >= 0 && x < w && y >= 0 && y < h end)
        |> Enum.reduce(data, fn {key, factor}, acc ->
          Map.update!(acc, key, &(&1 + err * factor))
        end)
    end

  for y <- 0..(h - 1), x <- 0..(w - 1), into: [] do
    Map.get(data, {x, y})
  end
end

new_dithered =
  grayscale
  # |> Nx.tensor()
  # |> Nx.reshape({height, width})
  |> dither.(width, height)

new_dithered |> Enum.uniq()
Enum.uniq(new_dithered)
dither_nx = fn tensor ->
  # offsets = Nx.tensor([[0, 1], [1, -1], [1, 0], [1, 1]])
  right = [0, 1]
  bottom_left = [1, -1]
  bottom = [1, 0]
  bottom_right = [1, 1]
  offsets = Nx.tensor([right, bottom_left, bottom, bottom_right])

  factors = Nx.tensor([7, 3, 5, 1]) |> Nx.divide(16)

  {h, w} = Nx.shape(tensor)

  # |> Nx.as_type(:u8)
  quantized = tensor |> Nx.divide(255) |> Nx.round()
  # |> Nx.round()
  error = tensor |> Nx.subtract(quantized)
  dbg(quantized)

  for y <- 1..(h - 2), x <- 1..(w - 2), reduce: tensor do
    pixels ->
      idx = Nx.tensor([y, x])

      indices =
        offsets
        |> Nx.add(Nx.tensor([y, x]))
        |> Nx.to_list()

      err =
        error
        |> Nx.gather(Nx.tensor([y, x]))
        |> Nx.multiply(factors)
        |> Nx.to_list()

      new =
        quantized
        |> Nx.gather(idx)

      pixels =
        pixels
        |> Nx.indexed_put(Nx.tensor([y, x]), new)

      indices
      |> Enum.zip(err)
      |> Enum.reduce(pixels, fn {idx, e}, acc ->
        Nx.indexed_add(acc, Nx.tensor(idx), e)
      end)

      pixels
      # |> Nx.indexed_add(indices, err)
  end

  # |> Nx.as_type(:u8)
end

nx_dithered =
  grayscale
  |> Nx.tensor()
  |> Nx.reshape({height, width})
  |> dither_nx.()
  |> Nx.slice([1, 1], [138, 158])

# new_dithered |> Enum.uniq()
dithered =
  grayscale
  |> Enum.chunk_every(160)
  |> apply_dithering.()
  |> List.flatten()
Enum.uniq(dithered)
rgb_img =
  rgb
  |> IO.iodata_to_binary()
  |> Nx.from_binary(:u8)
  |> Nx.reshape({height, width, 3})
  |> Kino.Image.new()

grayscale_img =
  grayscale
  |> IO.iodata_to_binary()
  |> Nx.from_binary(:u8)
  |> Nx.reshape({height, width, 1})
  |> Kino.Image.new()

dithered_img =
  dithered
  |> IO.iodata_to_binary()
  |> Nx.from_binary(:u8)
  |> Nx.reshape({height, width, 1})
  |> Nx.multiply(div(255, 3))
  |> Kino.Image.new()

new_dithered_img =
  new_dithered
  |> Enum.map(&trunc/1)
  |> IO.iodata_to_binary()
  |> Nx.from_binary(:u8)
  |> Nx.reshape({height, width, 1})
  |> Nx.multiply(div(255, 3))
  |> Kino.Image.new()

# nx_dithered_img =
#   nx_dithered
#   |> Nx.round()
#   |> Nx.as_type(:u8)
#   |> Nx.reshape({140-2, 160-2, 1})
#   |> Nx.multiply(div(255, 1))
#   |> Kino.Image.new()

Kino.Layout.grid(
  [
    Kino.Shorts.text("RGB"),
    # Kino.Shorts.text("Grayscale"),
    Kino.Shorts.text("Dithered"),
    Kino.Shorts.text("New Dithered"),
    rgb_img,
    # grayscale_img,
    dithered_img,
    new_dithered_img
    # nx_dithered_img
  ],
  columns: 3
)

Convert dithered image to tile data

rows = dithered |> Enum.chunk_every(tiles_per_row * tile_size * tile_size)
length(rows)
length(Enum.at(rows, 0))
tiles =
  rows
  |> Enum.flat_map(fn row ->
    tile_rows = row |> Enum.chunk_every(8)

    Enum.map(0..(tiles_per_row - 1), fn offset ->
      tile_rows |> Enum.drop(offset) |> Enum.take_every(tiles_per_row)
    end)
  end)
length(tiles)

Convert tile data to 2bpp

import Bitwise

tile_bytes =
  tiles
  |> Enum.map(fn rows ->
    Enum.map(rows, fn row ->
      row
      |> Enum.with_index()
      |> Enum.reduce([0, 0], fn {pixel, i}, [lo, hi] ->
        case pixel do
          0 -> [lo, hi]
          1 -> [lo ||| 1 <<< (7 - i), hi]
          2 -> [lo, hi ||| 1 <<< (7 - i)]
          3 -> [lo ||| 1 <<< (7 - i), hi ||| 1 <<< (7 - i)]
        end
      end)
    end)
  end)
IO.iodata_length(tile_bytes)
tiles_binary = tile_bytes |> IO.iodata_to_binary()
IO.inspect(tiles_binary, limit: :infinity, width: :infinity)
Kino.Download.new(fn -> tile_bytes |> IO.iodata_to_binary() end,
  label: "Image Data",
  filename: "image.bin"
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment