initial commit
This commit is contained in:
parent
b7dfa5ead8
commit
d4f5ff8a99
465
png_to_ilbm_strict_palette.rb
Executable file
465
png_to_ilbm_strict_palette.rb
Executable file
@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Read a PNG file and create a palette-locked IFF ILBM file
|
||||
# This allows for multiple files to be exported by other tools
|
||||
# with the same palette colors, and for the resulting IFF ILBM
|
||||
# images to contain those pixels, with those colors, with the
|
||||
# color indexes the same across all images.
|
||||
|
||||
# References:
|
||||
#
|
||||
# * http://etwright.org/lwsdk/docs/filefmts/ilbm.html
|
||||
|
||||
# * [ ] AMOS and ADPro fail on an image that's less than 8 pixels wide?
|
||||
# * DPaint can load it, other apps get weird
|
||||
|
||||
require 'rmagick'
|
||||
|
||||
class PixelColor
|
||||
attr_reader :r, :g, :b
|
||||
|
||||
def initialize(r:, g:, b:)
|
||||
@r = r
|
||||
@g = g
|
||||
@b = b
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
r == other.r && g == other.g && b == other.b
|
||||
end
|
||||
|
||||
def to_cmap
|
||||
[
|
||||
r * 16 + r,
|
||||
g * 16 + g,
|
||||
b * 16 + b
|
||||
]
|
||||
end
|
||||
|
||||
def inspect
|
||||
"RGB: #{r.to_s(16)},#{g.to_s(16)},#{b.to_s(16)}"
|
||||
end
|
||||
end
|
||||
|
||||
DESIRED_PALETTE = [
|
||||
[15,0,15],
|
||||
[0,0,0],
|
||||
[15,15,15],
|
||||
[5,0xd,3],
|
||||
[0xe, 0xe, 0],
|
||||
[0xe, 0xd, 0xa],
|
||||
[3, 8, 0xd],
|
||||
[4, 2, 2],
|
||||
[0xd, 0xe, 0xd],
|
||||
[0xe, 0xa, 0xe],
|
||||
[8, 1, 2],
|
||||
[3, 8, 2],
|
||||
[7, 6, 4],
|
||||
[5, 5, 5],
|
||||
[0xf, 0xf, 8],
|
||||
[0xe, 9, 3]
|
||||
].map { |p| PixelColor.new(r: p[0], g: p[1], b: p[2]) }
|
||||
|
||||
class Bitplane
|
||||
def initialize(bits:)
|
||||
@bits = bits
|
||||
end
|
||||
|
||||
def count
|
||||
@bits.count
|
||||
end
|
||||
|
||||
def [](idx)
|
||||
@bits[idx]
|
||||
end
|
||||
end
|
||||
|
||||
# Convert an RMagick image into bitplane data based on
|
||||
# the provided palette. Raises UnknownPaletteColor if there's a
|
||||
# color in the image that's not in the palette.
|
||||
class ImageComponentParser
|
||||
# @param image [Magick::Image]
|
||||
def self.parse(image:, desired_palette:)
|
||||
parser = new(image: image, desired_palette: desired_palette)
|
||||
parser.parse!
|
||||
parser
|
||||
end
|
||||
|
||||
UnknownPaletteColor = Class.new(ArgumentError)
|
||||
|
||||
def initialize(image:, desired_palette:)
|
||||
@image = image
|
||||
@desired_palette = desired_palette
|
||||
@bitplanes = nil
|
||||
end
|
||||
|
||||
def bitplane_count
|
||||
Math.sqrt(@desired_palette.length).ceil
|
||||
end
|
||||
|
||||
def bitplanes
|
||||
return @bitplanes if @bitplanes
|
||||
|
||||
bits = []
|
||||
# get around duplicate objects
|
||||
bitplane_count.times { |i| bits << [] }
|
||||
|
||||
@bytes.each do |byte|
|
||||
mask = 1
|
||||
bitplane_count.times do |bit|
|
||||
bits[bit] << (((byte & mask) == mask) ? 1 : 0)
|
||||
|
||||
mask = mask << 1
|
||||
end
|
||||
end
|
||||
|
||||
@bitplanes = bits.map { |b| Bitplane.new(bits: b) }
|
||||
end
|
||||
|
||||
def parse!
|
||||
@bytes = []
|
||||
@image.each_pixel do |px|
|
||||
pixel_color = PixelColor.new(
|
||||
# 4 bit amiga pixel colors
|
||||
r: px.red >> 12,
|
||||
g: px.green >> 12,
|
||||
b: px.blue >> 12
|
||||
)
|
||||
|
||||
index = @desired_palette.find_index { |p| p == pixel_color }
|
||||
unless index
|
||||
raise UnknownPaletteColor, "Palette color not found! Check export: #{pixel_color}"
|
||||
end
|
||||
|
||||
@bytes << index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module IFF
|
||||
module WithHeader
|
||||
def to_bytes
|
||||
data_bytes = to_data_bytes
|
||||
data_bytes_count = data_bytes.count
|
||||
|
||||
output = self.class.name.split("::").last.unpack("C*") +
|
||||
[data_bytes_count].pack("N").unpack("C*") +
|
||||
data_bytes
|
||||
|
||||
# pad chunk
|
||||
output << 0 if data_bytes_count.odd?
|
||||
|
||||
output
|
||||
end
|
||||
end
|
||||
|
||||
# Construct an IFF ILBM image from the provided data.
|
||||
class ILBMBuilder
|
||||
def initialize(
|
||||
width:,
|
||||
height:,
|
||||
palette:,
|
||||
bitplanes:,
|
||||
row_writer: CompressedRowWriter,
|
||||
screen_format: CAMG.PalLowRes
|
||||
)
|
||||
@width = width
|
||||
@height = height
|
||||
@palette = palette
|
||||
@bitplanes = bitplanes
|
||||
@row_writer = row_writer
|
||||
@screen_format = screen_format
|
||||
end
|
||||
|
||||
def row_writer
|
||||
return UncompressedRowWriter
|
||||
|
||||
# here's no reason to use compression if the image is less
|
||||
# than 24 pixels wide
|
||||
if @row_writer == CompressedRowWriter && @width < 24
|
||||
return UncompressedRowWriter
|
||||
end
|
||||
|
||||
@row_writer
|
||||
end
|
||||
|
||||
def build
|
||||
form = FORM.new
|
||||
|
||||
bmhd = BMHD.new
|
||||
bmhd.width = @width
|
||||
bmhd.height = @height
|
||||
bmhd.planes = Math.sqrt(@palette.length).ceil
|
||||
bmhd.compression = row_writer.bmhd_flag
|
||||
|
||||
cmap = CMAP.new(palette: @palette)
|
||||
|
||||
body = BODY.new(
|
||||
bitplanes: @bitplanes,
|
||||
width: @width,
|
||||
row_writer: row_writer
|
||||
)
|
||||
|
||||
ilbm = ILBM.new
|
||||
ilbm.bmhd = bmhd
|
||||
ilbm.cmap = cmap
|
||||
ilbm.body = body
|
||||
ilbm.camg = @screen_format
|
||||
|
||||
form << ilbm
|
||||
|
||||
form
|
||||
end
|
||||
end
|
||||
|
||||
# IFF wrapper
|
||||
class FORM
|
||||
include WithHeader
|
||||
|
||||
def initialize
|
||||
@chunks = []
|
||||
end
|
||||
|
||||
def <<(chunk)
|
||||
@chunks << chunk
|
||||
end
|
||||
|
||||
def to_data_bytes
|
||||
@chunks.flat_map(&:to_bytes)
|
||||
end
|
||||
|
||||
def to_s
|
||||
to_bytes.pack("C*")
|
||||
end
|
||||
end
|
||||
|
||||
# Image type header
|
||||
class ILBM
|
||||
attr_accessor :bmhd, :cmap, :body, :camg
|
||||
|
||||
def to_bytes
|
||||
"ILBM".unpack("C*") +
|
||||
bmhd.to_bytes +
|
||||
cmap.to_bytes +
|
||||
camg.to_bytes +
|
||||
# body is always last
|
||||
body.to_bytes
|
||||
end
|
||||
end
|
||||
|
||||
# Image properties
|
||||
class BMHD
|
||||
include WithHeader
|
||||
|
||||
ASPECT_RATIO = 0x2C
|
||||
|
||||
attr_accessor :width, :height, :planes, :compression
|
||||
|
||||
def to_data_bytes
|
||||
[
|
||||
*word_to_bytes(width),
|
||||
*word_to_bytes(height),
|
||||
0, 0,
|
||||
0, 0,
|
||||
planes,
|
||||
0,
|
||||
compression,
|
||||
0,
|
||||
0, 0,
|
||||
ASPECT_RATIO, ASPECT_RATIO,
|
||||
*word_to_bytes(width),
|
||||
*word_to_bytes(height),
|
||||
]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def word_to_bytes(word)
|
||||
[ word >> 8, word & 0xff ]
|
||||
end
|
||||
end
|
||||
|
||||
# Color map
|
||||
class CMAP
|
||||
include WithHeader
|
||||
|
||||
def initialize(palette:)
|
||||
@palette = palette
|
||||
end
|
||||
|
||||
def to_data_bytes
|
||||
@palette.flat_map(&:to_cmap)
|
||||
end
|
||||
end
|
||||
|
||||
# Amiga-related display properties
|
||||
class CAMG
|
||||
include WithHeader
|
||||
|
||||
def self.PalLowRes
|
||||
new(0x00021000)
|
||||
end
|
||||
|
||||
def initialize(setting)
|
||||
@setting = setting
|
||||
end
|
||||
|
||||
def to_data_bytes
|
||||
[@setting].pack("N").unpack("C*")
|
||||
end
|
||||
end
|
||||
|
||||
module AsRowWriter
|
||||
def initialize
|
||||
@data = []
|
||||
end
|
||||
|
||||
def <<(byte)
|
||||
@data << byte
|
||||
end
|
||||
|
||||
# Output pixel data is word padded
|
||||
def ensure_padded!
|
||||
@data << 0 if @data.length.odd?
|
||||
end
|
||||
end
|
||||
|
||||
# Write out uncompressed bitplane data
|
||||
class UncompressedRowWriter
|
||||
include AsRowWriter
|
||||
|
||||
def self.bmhd_flag
|
||||
0
|
||||
end
|
||||
|
||||
def to_bytes
|
||||
ensure_padded!
|
||||
|
||||
@data
|
||||
end
|
||||
end
|
||||
|
||||
# Write out compressed bitplane data using the
|
||||
# ByteRun1 run encoding algorithm.
|
||||
class CompressedRowWriter
|
||||
include AsRowWriter
|
||||
|
||||
def self.bmhd_flag
|
||||
1
|
||||
end
|
||||
|
||||
def to_bytes
|
||||
ensure_padded!
|
||||
|
||||
runs = []
|
||||
|
||||
# discover initial runs
|
||||
# format is [byte, run count]
|
||||
@data.each do |byte|
|
||||
if !runs.last
|
||||
runs << [byte, 1]
|
||||
elsif runs.last.first == byte
|
||||
runs.last[1] += 1
|
||||
else
|
||||
runs << [byte, 1]
|
||||
end
|
||||
end
|
||||
|
||||
# categorize and pack literals
|
||||
# formats are:
|
||||
# * [:repeat, byte_count]
|
||||
# * [:literal, byte, ...]
|
||||
runs = runs.each_with_object([]) do |(byte, count), out|
|
||||
if count > 1
|
||||
out << [:repeat, byte, count]
|
||||
else
|
||||
# pack literals together
|
||||
if out.last&.first != :literal
|
||||
out << [:literal]
|
||||
end
|
||||
|
||||
out.last << byte
|
||||
end
|
||||
end
|
||||
|
||||
# write out compressed data
|
||||
runs.each_with_object([]) do |type, obj|
|
||||
case type.first
|
||||
when :repeat
|
||||
# signed int
|
||||
obj << (129 - type[2]) + 128
|
||||
obj << type[1]
|
||||
when :literal
|
||||
# n-1
|
||||
obj << type.length - 2
|
||||
obj.concat(type[1..])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Pixel data
|
||||
class BODY
|
||||
include WithHeader
|
||||
|
||||
def initialize(bitplanes:, width:, row_writer:)
|
||||
@bitplanes = bitplanes
|
||||
@width = width
|
||||
@row_writer = row_writer
|
||||
end
|
||||
|
||||
START_MASK = 1 << 7
|
||||
|
||||
def to_data_bytes
|
||||
rows = []
|
||||
|
||||
row_count = @bitplanes[0].count / @width
|
||||
|
||||
row_count.times do |y|
|
||||
# interleave the bitplanes
|
||||
pos = y * @width
|
||||
|
||||
@bitplanes.each do |bitplane|
|
||||
val = 0
|
||||
mask = START_MASK
|
||||
|
||||
# the row writer takes care of turning bitplane data into output
|
||||
current_row_writer = @row_writer.new
|
||||
|
||||
@width.times do |x|
|
||||
val = bitplane[pos + x] * mask | val
|
||||
mask >>= 1
|
||||
|
||||
# end of byte
|
||||
if mask == 0
|
||||
current_row_writer << val
|
||||
|
||||
val = 0
|
||||
mask = START_MASK
|
||||
end
|
||||
end
|
||||
|
||||
unless mask == START_MASK
|
||||
current_row_writer << val
|
||||
end
|
||||
|
||||
rows.concat(current_row_writer.to_bytes)
|
||||
end
|
||||
end
|
||||
|
||||
rows
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
image = Magick::Image.read(ARGV[0]).first
|
||||
parsed = ImageComponentParser.parse(image: image, desired_palette: DESIRED_PALETTE)
|
||||
|
||||
form = IFF::ILBMBuilder.new(
|
||||
width: image.columns,
|
||||
height: image.rows,
|
||||
palette: DESIRED_PALETTE,
|
||||
bitplanes: parsed.bitplanes,
|
||||
).build
|
||||
|
||||
File.open(ARGV[1], 'wb') { |fh| fh.puts form.to_s }
|
Loading…
Reference in New Issue
Block a user