First cut at NMOS IC analysis, can separate layers into contiguous regions with numer...
authorNick Downing <nick@ndcode.org>
Mon, 14 Jul 2025 17:19:39 +0000 (03:19 +1000)
committerNick Downing <nick@ndcode.org>
Mon, 14 Jul 2025 17:20:07 +0000 (03:20 +1000)
22 files changed:
.gitignore [new file with mode: 0644]
Makefile [new file with mode: 0644]
boolean.py [new file with mode: 0755]
layer.py [new file with mode: 0755]
layer_id.py [new file with mode: 0755]
layers.py [new file with mode: 0755]
layers_net.py [new file with mode: 0755]
nets.py [new file with mode: 0755]
orig/AMD_8085_Buried_7267w.png [new file with mode: 0644]
orig/AMD_8085_Buried_orig_7267w.png [new file with mode: 0644]
orig/AMD_8085_Diffusion_7267w.png [new file with mode: 0644]
orig/AMD_8085_Diffusion_orig_7267w.png [new file with mode: 0644]
orig/AMD_8085_Metal_7267w.png [new file with mode: 0644]
orig/AMD_8085_Metal_orig_7267w.png [new file with mode: 0644]
orig/AMD_8085_Pads_7267w.png [new file with mode: 0644]
orig/AMD_8085_Pads_orig_7267w.png [new file with mode: 0644]
orig/AMD_8085_Polysilicon_7267w.png [new file with mode: 0644]
orig/AMD_8085_Polysilicon_orig_7267w.png [new file with mode: 0644]
orig/AMD_8085_Vias_7267w.png [new file with mode: 0644]
orig/AMD_8085_Vias_orig_7267w.png [new file with mode: 0644]
to_mono.py [new file with mode: 0755]
to_rgb.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..1ef832f
--- /dev/null
@@ -0,0 +1,2 @@
+/*.png
+/*.txt
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..4028978
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,124 @@
+all: \
+net_gnd.png \
+net_vcc.png \
+metal_gnd.png \
+metal_vcc.png \
+#buried2.png \
+#diff2.png \
+#metal2.png \
+#pads2.png \
+#poly2.png \
+#vias2.png
+
+# GND is metal ID 0, corresponding net is found as follows:
+# grep ' 1 0$' nets.txt
+net_gnd.png: layers.txt nets.txt
+       ./layers_net.py $^ 17 $@
+
+# VCC is metal ID 1, corresponding net is found as follows:
+# grep ' 1 1$' nets.txt
+net_vcc.png: layers.txt nets.txt
+       ./layers_net.py $^ 21 $@
+
+nets.txt: layers.txt
+       ./nets.py <$< >$@
+
+layers.txt: \
+pads.txt \
+metal.txt \
+vias.txt \
+poly.txt \
+buried.txt \
+drain_source.txt \
+split_diff.txt
+       ./layers.py pads.txt,metal.txt,vias.txt,poly.txt,buried.txt,drain_source.txt,split_diff.txt 0:1,1:2,2:3,2:6,3:4,3:5,4:6,5:6 >$@
+
+metal_gnd.png: metal.txt
+       ./layer_id.py $< 0 $@
+
+metal_vcc.png: metal.txt
+       ./layer_id.py $< 1 $@
+
+drain_source.txt: drain_source.png
+       ./layer.py $< >$@
+
+split_diff.txt: split_diff.png
+       ./layer.py $< >$@
+
+buried.txt: buried.png
+       ./layer.py $< >$@
+
+metal.txt: metal.png
+       ./layer.py $< >$@
+
+pads.txt: pads.png
+       ./layer.py $< >$@
+
+poly.txt: poly.png
+       ./layer.py $< >$@
+
+vias.txt: vias.png
+       ./layer.py $< >$@
+
+drain_source.png: split_diff.png poly.png buried.png
+       ./boolean.py 'split_diff.png&poly.png&~+buried.png' $@
+
+split_diff.png: diff.png poly.png buried.png
+       ./boolean.py 'diff.png&~(-poly.png&~buried.png)' $@
+
+buried.png: orig/AMD_8085_Buried_orig_7267w.png
+       ./to_mono.py $< $@
+
+diff.png: orig/AMD_8085_Diffusion_orig_7267w.png
+       ./to_mono.py $< $@
+
+metal.png: orig/AMD_8085_Metal_orig_7267w.png
+       ./to_mono.py $< $@
+
+pads.png: orig/AMD_8085_Pads_orig_7267w.png
+       ./to_mono.py $< $@
+
+poly.png: orig/AMD_8085_Polysilicon_orig_7267w.png
+       ./to_mono.py $< $@
+
+vias.png: orig/AMD_8085_Vias_orig_7267w.png
+       ./to_mono.py $< $@
+
+buried1.png: orig/AMD_8085_Buried_7267w.png
+       ./to_mono.py $< $@
+
+diff1.png: orig/AMD_8085_Diffusion_7267w.png
+       ./to_mono.py $< $@
+
+metal1.png: orig/AMD_8085_Metal_7267w.png
+       ./to_mono.py $< $@
+
+pads1.png: orig/AMD_8085_Pads_7267w.png
+       ./to_mono.py $< $@
+
+poly1.png: orig/AMD_8085_Polysilicon_7267w.png
+       ./to_mono.py $< $@
+
+vias1.png: orig/AMD_8085_Vias_7267w.png
+       ./to_mono.py $< $@
+
+buried2.png: buried1.png
+       ./boolean.py 'buried.png^buried1.png' $@
+
+diff2.png: diff1.png
+       ./boolean.py 'diff.png^diff1.png' $@
+
+metal2.png: metal1.png
+       ./boolean.py 'metal.png^metal1.png' $@
+
+pads2.png: pads1.png
+       ./boolean.py 'pads.png^pads1.png' $@
+
+poly2.png: poly1.png
+       ./boolean.py 'poly.png^poly1.png' $@
+
+vias2.png: vias1.png
+       ./boolean.py 'vias.png^vias1.png' $@
+
+clean:
+       rm -f *.png *.txt
diff --git a/boolean.py b/boolean.py
new file mode 100755 (executable)
index 0000000..dcc7fb7
--- /dev/null
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+
+import PIL.Image
+import numpy
+import sys
+
+EPSILON = 1e-6
+
+PIL.Image.warnings.simplefilter('ignore', PIL.Image.DecompressionBombWarning)
+
+if len(sys.argv) < 3:
+  print(f'usage: {sys.argv[0]:s} expr image_out')
+  sys.exit(1)
+expr = sys.argv[1]
+image_out = sys.argv[2]
+
+ops = set(['~', '-', '+', '&', '^', '|', ')'])
+def expr0(i):
+  if i < len(expr) and expr[i] == '(':
+    i += 1
+    image, i = expr3(i)
+    assert expr[i] == ')'
+    i += 1
+    return image, i
+  if i < len(expr) and expr[i] == '~':
+    i += 1
+    image, i = expr0(i)
+    return numpy.logical_not(image), i
+  if i < len(expr) and expr[i] == '-':
+    # shrink by one pixel
+    i += 1
+    image, i = expr0(i)
+    image1 = numpy.ones_like(image)
+    image1[:-1, :-1] &= image[1:, 1:]
+    image1[:-1, :] &= image[1:, :]
+    image1[:-1, 1:] &= image[1:, :-1]
+    image1[:, :-1] &= image[:, 1:]
+    image1[:, :] &= image[:, :]
+    image1[:, 1:] &= image[:, :-1]
+    image1[1:, :-1] &= image[:-1, 1:]
+    image1[1:, :] &= image[:-1, :]
+    image1[1:, 1:] &= image[:-1, :-1]
+    return image1, i
+  if i < len(expr) and expr[i] == '+':
+    # expand by one pixel
+    i += 1
+    image, i = expr0(i)
+    image1 = numpy.zeros_like(image)
+    image1[:-1, :-1] |= image[1:, 1:]
+    image1[:-1, :] |= image[1:, :]
+    image1[:-1, 1:] |= image[1:, :-1]
+    image1[:, :-1] |= image[:, 1:]
+    image1[:, :] |= image[:, :]
+    image1[:, 1:] |= image[:, :-1]
+    image1[1:, :-1] |= image[:-1, 1:]
+    image1[1:, :] |= image[:-1, :]
+    image1[1:, 1:] |= image[:-1, :-1]
+    return image1, i
+  j = i
+  while j < len(expr) and expr[j] not in ops:
+    j += 1
+  return numpy.array(PIL.Image.open(expr[i:j])), j
+def expr1(i):
+  image, i = expr0(i)
+  while i < len(expr) and expr[i] == '&':
+    i += 1
+    image1, i = expr0(i)
+    image = numpy.logical_and(image, image1)
+  return image, i
+def expr2(i):
+  image, i = expr1(i)
+  while i < len(expr) and expr[i] == '^':
+    i += 1
+    image1, i = expr1(i)
+    image = numpy.logical_xor(image, image1)
+  return image, i
+def expr3(i):
+  image, i = expr2(i)
+  while i < len(expr) and expr[i] == '|':
+    i += 1
+    image1, i = expr2(i)
+    image = numpy.logical_or(image, image1)
+  return image, i
+image, i = expr3(0)
+assert i == len(expr)
+PIL.Image.fromarray(image).save(image_out)
diff --git a/layer.py b/layer.py
new file mode 100755 (executable)
index 0000000..9c8d33e
--- /dev/null
+++ b/layer.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+
+import PIL.Image
+import numpy
+import sys
+
+PIL.Image.warnings.simplefilter('ignore', PIL.Image.DecompressionBombWarning)
+
+if len(sys.argv) < 2:
+  print(
+    f'usage: {sys.argv[0]:s} image_in'
+  )
+  sys.exit(1)
+image_in = sys.argv[1]
+
+image = PIL.Image.open(image_in)
+image.load()
+#print('mode', image.mode)
+#print('palette', image.palette)
+#print('width', image.width)
+#print('height', image.height)
+image = numpy.array(image)
+#print('shape', image.shape)
+#print('dtype', image.dtype)
+assert len(image.shape) == 2 and image.dtype == bool
+ys, xs = image.shape
+
+image[:, 1:] ^= image[:, :-1]
+nz_y, nz_x = numpy.nonzero(image)
+
+ptrs = []
+items = []
+for i in range(0, nz_y.shape[0], 2):
+  y = nz_y[i]
+  while len(ptrs) <= y:
+    ptrs.append(len(items))
+  items.append([len(items), y, nz_x[i], nz_x[i + 1]])
+while len(ptrs) <= ys:
+  ptrs.append(len(items))
+
+for i in range(ys - 1):
+  p = ptrs[i]
+  q = ptrs[i + 1]
+  r = ptrs[i + 2]
+  if q > p and r > q:
+    j = p
+    jid, _, jx0, jx1 = items[j]
+    k = q
+    kid, _, kx0, kx1 = items[k]
+    while True:
+      if kx0 >= jx1:
+        j += 1
+        if j >= q:
+          break
+        jid, _, jx0, jx1 = items[j]
+      elif jx0 >= kx1:
+        k += 1
+        if k >= r:
+          break
+        kid, _, kx0, kx1 = items[k]
+      else:
+        l = items[jid][0]
+        while l != jid:
+          jid = l
+          l = items[jid][0]
+
+        l = items[kid][0]
+        while l != kid:
+          kid = l
+          l = items[kid][0]
+
+        id = min(jid, kid)
+        items[jid][0] = id
+        items[kid][0] = id
+
+        jid = j
+        while jid != id:
+          l = items[jid][0]
+          items[jid][0] = id
+          jid = l
+
+        kid = k
+        while kid != id:
+          l = items[kid][0]
+          items[kid][0] = id
+          kid = l
+        if jx1 < kx1:
+          j += 1
+          if j >= q:
+            break
+          jid, _, jx0, jx1 = items[j]
+        else:
+          k += 1
+          if k >= r:
+            break
+          kid, _, kx0, kx1 = items[k]
+
+print(ys, xs)
+for i in range(len(items)):
+  id, y, x0, x1 = items[i]
+
+  l = items[id][0]
+  while l != id:
+    id = l
+    l = items[id][0]
+
+  l = i
+  while l != id:
+    m = items[l][0]
+    items[l][0] = id
+    l = m
+
+  print(id, y, x0, x1)
diff --git a/layer_id.py b/layer_id.py
new file mode 100755 (executable)
index 0000000..0a73c0b
--- /dev/null
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+import PIL.Image
+import numpy
+import sys
+
+if len(sys.argv) < 4:
+  print(
+    f'usage: {sys.argv[0]:s} layer.txt id image_out'
+  )
+  sys.exit(1)
+layer_txt = sys.argv[1]
+id = int(sys.argv[2])
+image_out = sys.argv[3]
+
+with open(layer_txt) as fin:
+  line = fin.readline()
+  assert len(line)
+  ys, xs = [int(i) for i in line.split()]
+  image = numpy.zeros((ys, xs), bool)
+
+  line = fin.readline()
+  while len(line):
+    id1, y, x0, x1 = [int(i) for i in line.split()]
+    if id1 == id:
+      image[y, x0:x1] = True
+    line = fin.readline()
+PIL.Image.fromarray(image).save(image_out)
diff --git a/layers.py b/layers.py
new file mode 100755 (executable)
index 0000000..363708b
--- /dev/null
+++ b/layers.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+
+import sys
+
+if len(sys.argv) < 3:
+  print(
+    f'usage: {sys.argv[0]:s} layer0.txt,layer1.txt,... l0:l1,l0:l2,...'
+  )
+  sys.exit(1)
+layers_in = sys.argv[1].split(',')
+layer_pairs = []
+for i in sys.argv[2].split(','):
+  l0, l1 = [int(j) for j in i.split(':')]
+  layer_pairs.append((l0, l1))
+
+layers = []
+for layer_in in layers_in:
+  with open(layer_in) as fin:
+    line = fin.readline()
+    assert len(line)
+    ys, xs = [int(i) for i in line.split()]
+
+    ptrs = []
+    items = []
+    line = fin.readline()
+    while len(line):
+      id, y, x0, x1 = [int(i) for i in line.split()]
+      while len(ptrs) <= y:
+        ptrs.append(len(items))
+      items.append((id, y, x0, x1))
+      line = fin.readline()
+    while len(ptrs) <= ys:
+      ptrs.append(len(items))
+  layers.append(((ys, xs), ptrs, items))
+(ys, xs) = layers[0][0]
+assert all([i[0] == (ys, xs) for i in layers])
+
+print(len(layers), ys, xs)
+for _, _, items in layers:
+  print(len(items))
+  for id, y, x0, x1 in items:
+    print(id, y, x0, x1)
+
+for l0, l1 in layer_pairs:
+  _, ptrs0, items0 = layers[l0]
+  _, ptrs1, items1 = layers[l1]
+
+  found = set()
+  for i in range(ys):
+    p = ptrs0[i]
+    q = ptrs0[i + 1]
+    r = ptrs1[i]
+    s = ptrs1[i + 1]
+    if q > p and s > r:
+      j = p
+      jid, _, jx0, jx1 = items0[j]
+      k = r
+      kid, _, kx0, kx1 = items1[k]
+      while True:
+        if kx0 >= jx1:
+          j += 1
+          if j >= q:
+            break
+          jid, _, jx0, jx1 = items0[j]
+        elif jx0 >= kx1:
+          k += 1
+          if k >= s:
+            break
+          kid, _, kx0, kx1 = items1[k]
+        else:
+          key = (jid, kid)
+          if key not in found:
+            found.add(key)
+            print(l0, l1, jid, kid)
+          if jx1 < kx1:
+            j += 1
+            if j >= q:
+              break
+            jid, _, jx0, jx1 = items0[j]
+          else:
+            k += 1
+            if k >= s:
+              break
+            kid, _, kx0, kx1 = items1[k]
diff --git a/layers_net.py b/layers_net.py
new file mode 100755 (executable)
index 0000000..b3fe8ca
--- /dev/null
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+
+import PIL.Image
+import numpy
+import sys
+
+if len(sys.argv) < 5:
+  print(
+    f'usage: {sys.argv[0]:s} layers.txt nets.txt net image_out'
+  )
+  sys.exit(1)
+layers_txt = sys.argv[1]
+nets_txt = sys.argv[2]
+net = int(sys.argv[3])
+image_out = sys.argv[4]
+
+with open(layers_txt) as fin:
+  line = fin.readline()
+  assert len(line)
+  n_layers, ys, xs = [int(i) for i in line.split()]
+
+  items = []
+  layers = []
+  for i in range(n_layers):
+    line = fin.readline()
+    assert len(line)
+    n_items = int(line.rstrip())
+    ids = {}
+    for j in range(n_items):
+      line = fin.readline()
+      assert len(line)
+      id, y, x0, x1 = [int(i) for i in line.split()]
+      if id not in ids:
+        ids[id] = []
+      ids[id].append((y, x0, x1))
+    layers.append(ids)
+
+image = numpy.zeros((ys, xs, 3), numpy.uint8)
+with open(nets_txt) as fin:
+  line = fin.readline()
+  while len(line):
+    net1, layer, id = [int(i) for i in line.split()]
+    if net1 == net:
+      c = layer + 1
+      c = numpy.array(
+        [((c >> 2) & 1) * 0xff, ((c >> 1) & 1) * 0xff, (c & 1) * 0xff],
+        numpy.uint8
+      )
+      for y, x0, x1 in layers[layer][id]:
+        image[y, x0:x1, :] = c[numpy.newaxis, :]
+    line = fin.readline()
+
+PIL.Image.fromarray(image).save(image_out)
diff --git a/nets.py b/nets.py
new file mode 100755 (executable)
index 0000000..3491e5e
--- /dev/null
+++ b/nets.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+import numpy
+import sys
+
+line = sys.stdin.readline()
+assert len(line)
+n_layers, ys, xs = [int(i) for i in line.split()]
+
+items = []
+layers = []
+for i in range(n_layers):
+  line = sys.stdin.readline()
+  assert len(line)
+  n_items = int(line.rstrip())
+  ids = {}
+  for j in range(n_items):
+    line = sys.stdin.readline()
+    assert len(line)
+    id, _, _, _ = [int(i) for i in line.split()]
+    if id not in ids:
+      ids[id] = len(items)
+      items.append([len(items), i, id])
+  layers.append(ids)
+
+line = sys.stdin.readline()
+while len(line):
+  l0, l1, id0, id1 = [int(i) for i in line.split()]
+  # skip drain_source layer which separates nets by transistors
+  if l0 != 5 and l1 != 5:
+    net0 = layers[l0][id0]
+    n = items[net0][0]
+    while n != net0:
+      net0 = n
+      n = items[net0][0]
+    
+    net1 = layers[l1][id1]
+    n = items[net1][0]
+    while n != net1:
+      net1 = n
+      n = items[net1][0]
+
+    net = min(net0, net1)
+
+    net0 = layers[l0][id0]
+    while net0 != net:
+      n = items[net0][0]
+      items[net0][0] = net
+      net0 = n
+
+    net1 = layers[l1][id1]
+    while net1 != net:
+      n = items[net1][0]
+      items[net1][0] = net
+      net1 = n
+  line = sys.stdin.readline()
+
+for i in range(len(items)):
+  net, layer, id = items[i]
+
+  n = items[net][0]
+  while n != net:
+    net = n
+    n = items[net][0]
+
+  n = i
+  while n != net:
+    o = items[n][0]
+    items[n][0] = net
+    n = o
+
+  print(net, layer, id)
diff --git a/orig/AMD_8085_Buried_7267w.png b/orig/AMD_8085_Buried_7267w.png
new file mode 100644 (file)
index 0000000..14f1b63
Binary files /dev/null and b/orig/AMD_8085_Buried_7267w.png differ
diff --git a/orig/AMD_8085_Buried_orig_7267w.png b/orig/AMD_8085_Buried_orig_7267w.png
new file mode 100644 (file)
index 0000000..9efc159
Binary files /dev/null and b/orig/AMD_8085_Buried_orig_7267w.png differ
diff --git a/orig/AMD_8085_Diffusion_7267w.png b/orig/AMD_8085_Diffusion_7267w.png
new file mode 100644 (file)
index 0000000..6afc84c
Binary files /dev/null and b/orig/AMD_8085_Diffusion_7267w.png differ
diff --git a/orig/AMD_8085_Diffusion_orig_7267w.png b/orig/AMD_8085_Diffusion_orig_7267w.png
new file mode 100644 (file)
index 0000000..e6b010a
Binary files /dev/null and b/orig/AMD_8085_Diffusion_orig_7267w.png differ
diff --git a/orig/AMD_8085_Metal_7267w.png b/orig/AMD_8085_Metal_7267w.png
new file mode 100644 (file)
index 0000000..1f9fb72
Binary files /dev/null and b/orig/AMD_8085_Metal_7267w.png differ
diff --git a/orig/AMD_8085_Metal_orig_7267w.png b/orig/AMD_8085_Metal_orig_7267w.png
new file mode 100644 (file)
index 0000000..1319d34
Binary files /dev/null and b/orig/AMD_8085_Metal_orig_7267w.png differ
diff --git a/orig/AMD_8085_Pads_7267w.png b/orig/AMD_8085_Pads_7267w.png
new file mode 100644 (file)
index 0000000..64ceed4
Binary files /dev/null and b/orig/AMD_8085_Pads_7267w.png differ
diff --git a/orig/AMD_8085_Pads_orig_7267w.png b/orig/AMD_8085_Pads_orig_7267w.png
new file mode 100644 (file)
index 0000000..0d5205e
Binary files /dev/null and b/orig/AMD_8085_Pads_orig_7267w.png differ
diff --git a/orig/AMD_8085_Polysilicon_7267w.png b/orig/AMD_8085_Polysilicon_7267w.png
new file mode 100644 (file)
index 0000000..87c2a04
Binary files /dev/null and b/orig/AMD_8085_Polysilicon_7267w.png differ
diff --git a/orig/AMD_8085_Polysilicon_orig_7267w.png b/orig/AMD_8085_Polysilicon_orig_7267w.png
new file mode 100644 (file)
index 0000000..a0d806e
Binary files /dev/null and b/orig/AMD_8085_Polysilicon_orig_7267w.png differ
diff --git a/orig/AMD_8085_Vias_7267w.png b/orig/AMD_8085_Vias_7267w.png
new file mode 100644 (file)
index 0000000..115324c
Binary files /dev/null and b/orig/AMD_8085_Vias_7267w.png differ
diff --git a/orig/AMD_8085_Vias_orig_7267w.png b/orig/AMD_8085_Vias_orig_7267w.png
new file mode 100644 (file)
index 0000000..7f65727
Binary files /dev/null and b/orig/AMD_8085_Vias_orig_7267w.png differ
diff --git a/to_mono.py b/to_mono.py
new file mode 100755 (executable)
index 0000000..e4b2b3f
--- /dev/null
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+
+import PIL.Image
+import numpy
+import sys
+
+PIL.Image.warnings.simplefilter('ignore', PIL.Image.DecompressionBombWarning)
+
+if len(sys.argv) < 3:
+  print(
+    f'usage: {sys.argv[0]:s} image_in image_out'
+  )
+  sys.exit(1)
+image_in = sys.argv[1]
+image_out = sys.argv[2]
+
+image = PIL.Image.open(image_in)
+image.load()
+#print('mode', image.mode)
+#print('palette', image.palette)
+#print('width', image.width)
+#print('height', image.height)
+image = numpy.array(image)
+#print('shape', image.shape)
+#print('dtype', image.dtype)
+assert len(image.shape) == 3 and image.dtype == numpy.uint8
+
+image = image != 0
+if image.shape[2] == 2 or image.shape[2] == 4:
+  image = image[:, :, :-1] & image[:, :, -1:]
+PIL.Image.fromarray(numpy.any(image, 2)).save(image_out)
diff --git a/to_rgb.py b/to_rgb.py
new file mode 100755 (executable)
index 0000000..1b95d46
--- /dev/null
+++ b/to_rgb.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+
+import PIL.Image
+import numpy
+import sys
+
+PIL.Image.warnings.simplefilter('ignore', PIL.Image.DecompressionBombWarning)
+
+if len(sys.argv) < 3:
+  print(
+    f'usage: {sys.argv[0]:s} image_in,image_in... image_out'
+  )
+  sys.exit(1)
+images_in = sys.argv[1].split(',')
+image_out = sys.argv[2]
+
+images = []
+for image_in in images_in:
+  image = PIL.Image.open(image_in)
+  image.load()
+  #print('mode', image.mode)
+  #print('palette', image.palette)
+  #print('width', image.width)
+  #print('height', image.height)
+  image = numpy.array(image)
+  #print('shape', image.shape)
+  #print('dtype', image.dtype)
+  assert len(image.shape) == 2 and image.dtype == bool
+  images.append(image)
+assert all([i.shape == images[0].shape for i in images])
+images = (images + [numpy.zeros_like(images[0])] * 3)[:3]
+image = numpy.stack(images, 2)
+
+PIL.Image.fromarray(image.astype(numpy.uint8) * 0xff).save(image_out)