みみけっと43・お疲れ様でした

2020年2月22日という、「2」がいっぱい付いた日の開催でした。そして、2020年最初の同人イベントへの参加でもありました。

新刊ではシリカとレンのマンガを描きました。描き方を変更してから最初の同人誌となるのですが、自分としては「線が太い」とか「塗り方がちょっと荒い」といった部分もあるものの、フルカラーで描くことが出来ました。

ただ、R-18な内容として相応しかったかどうかについてはだいぶ疑問な気がしたり…(表紙からしてそんな感じ)

それから、いままで描いてこなかったアナログイラストを少し初めてみました。デジタルでの作画と雰囲気がだいぶ変わってしまいますが、多少はマシに描けるようになりたいところ。

新刊について

今回の本ですが、会場内に委託ブースを設けていたメロンブックスさんに委託しました。イベント会場でそのまま委託出来るのはなんだか不思議。

ArtRageのペン設定

ArtRageのペン設定をPencilからInkPenに切り替えました。

今まで使用していたのがこちら。

Pencil 24

現在使用しているのがこちら。

みみけっと43に参加します

2020年2月22日に開催予定の「みみけっと43」に参加します。

お品書き

新刊

新刊は設定をほとんど考慮せず、きままに描いたマンガっぽい本となります。

内容はについてはアレ(18禁)ですので、うっかり興味を持ってしまった方はPixivに掲載されているサンプルをご確認ください。

https://www.pixiv.net/artworks/79530438

NNCとNNabla

SONYのNeural Network Consoleは、ディープラーニングのモデル作成をGUIベースで作成することが出来て便利なのですが、ウェブサービスに組み込みたいといった場合に情報が乏しい気がしました。

というわけでメモとして作成してみました。

ここにはコードの抜粋だけですが、GitHubにすべてのコードアップロードしています。

https://github.com/MizunagiKB/vividface

NNCで作成したモデルをPythonコード化

まずは以下のようなモデルをNNCで作成・学習を行いました。このモデルはVivid Strike!に登場する7キャラクターを分類する為に作成したものです。

学習が完了したらをExport > NNP (Neural Network Libraries file format)すると、nnpファイルが生成出来ます。

具体的には以下の様にして使用します。

# 初期化
nn.clear_parameters()
# 生成したnnpファイルをMODEL_PATHNAMEに指定。
nnc_nnp = U.nnp_graph.NnpLoader(MODEL_PATHNAME)
# 利用するモデル名称(大抵はMainRuntime)とバッチ数(ここでは1)を指定。
nnc_net = nnc_nnp.get_network("MainRuntime", 1)

# 入力と出力を取得します。
x = nnc_net.inputs["Input"]
f = nnc_net.outputs["Softmax"]

# バッチ数が1なのでひとつだけ指定し、推論を実行。
x.d = [nnl_image]
f.forward()

# f.dに値が入っています。
return f.d[0].tolist()

学習したモデルをPythonから利用する例

# ------------------------------------------------------------------ import(s)
import sys
import os
import random
import csv

import numpy as np

# -------------------------------------------------------------------- nnabla
import nnabla as nn
import nnabla.functions as F
import nnabla.parametric_functions as PF
import nnabla.solvers as S
import nnabla.logger as L
import nnabla.utils as U
import nnabla.utils.image_utils
import nnabla.utils.nnp_graph

try:
    import nnabla.ext_utils
    ctx = nnabla.ext_utils.get_extension_context("cudnn")
    nn.set_default_context(ctx)
except:
    pass

TARGET_CHARA = {
    0: "corona",
    1: "einhald",
    2: "fuka",
    3: "miura",
    4: "rinne",
    5: "rio",
    6: "vivio"
}
MODEL_PATHNAME = "vividface_nnc_model/model.nnp"
DATASET_DIR = "./dataset"
IMAGE_W = 48
IMAGE_H = 48
IMAGE_D = 3
LOAD_SIZE = 100
BATCH_SIZE = len(TARGET_CHARA) * 20


# ------------------------------------------------------------------- class(s)
class CDataset(object):
    def __init__(self, chara_name, filename, nnl_image, nnl_onehot):
        self.chara_name = chara_name
        self.filename = filename
        self.nnl_image = nnl_image
        self.nnl_onehot = nnl_onehot


# ---------------------------------------------------------------- function(s)
def valid(list_valid):

    nn.clear_parameters()
    nnc_nnp = U.nnp_graph.NnpLoader(MODEL_PATHNAME)
    nnc_net = nnc_nnp.get_network("MainRuntime", len(list_valid))

    x = nnc_net.inputs["Input"]
    f = nnc_net.outputs["Softmax"]

    list_size = [0] * len(TARGET_CHARA)
    list_true = [0] * len(TARGET_CHARA)
    list_ans = [0] * len(TARGET_CHARA)

    list_x = [o.nnl_image for o in list_valid]
    list_y = [o.nnl_onehot for o in list_valid]

    x.d = list_x

    f.forward(clear_buffer=True)

    for y, result in zip(list_y, f.d):
        v_max = max(result)
        n_idx = result.tolist().index(v_max)
        list_onehot = [0] * len(TARGET_CHARA)
        list_onehot[n_idx] = 1

        list_size[y.index(1)] += 1
        list_ans[n_idx] += 1
        if list_onehot == y:
            list_true[n_idx] += 1

    list_result = []
    for size, true in zip(list_size, list_true):
        if size > 0:
            list_result.append(true / size)
        else:
            list_result.append(0)

    L.info("size     " + " ".join(["%6.2f" % v for v in list_size]))
    L.info("true     " + " ".join(["%6.2f" % v for v in list_true]))
    L.info("ans      " + " ".join(["%6.2f" % v for v in list_ans]))
    L.info("result   " + " ".join(["%6.2f" % v for v in list_result]))

    return list_result


def inference(nnl_image):

    nn.clear_parameters()
    nnc_nnp = U.nnp_graph.NnpLoader(MODEL_PATHNAME)
    nnc_net = nnc_nnp.get_network("MainRuntime", 1)

    x = nnc_net.inputs["Input"]
    f = nnc_net.outputs["Softmax"]

    x.d = [nnl_image]

    f.forward()

    return f.d[0].tolist()


def main():

    list_train = []
    list_valid = []

    for dir_name, _, list_filename in os.walk(DATASET_DIR):
        _, chara_name = os.path.split(dir_name)
        if chara_name in TARGET_CHARA.values():
            list_image = []
            for filename in list_filename:
                if os.path.splitext(filename)[1].lower() in (".png", ".jpg",
                                                             ".jpeg"):

                    nnl_image = U.image_utils.imread(os.path.join(
                        dir_name, filename),
                                                     size=(IMAGE_W, IMAGE_H),
                                                     channel_first=False)

                    nnl_image = nnl_image.transpose(2, 0, 1)

                    list_onehot = [0] * len(TARGET_CHARA)
                    dict_chara = {v: k for k, v in TARGET_CHARA.items()}
                    list_onehot[dict_chara[chara_name]] = 1

                    list_image.append(
                        CDataset(chara_name, filename, nnl_image / 255.0,
                                 list_onehot))

                if len(list_image) == LOAD_SIZE:
                    break

            list_train += list_image[0:-20]
            list_valid += list_image[-20:]

            L.info("%s %4d %4d" %
                   (chara_name, len(list_train), len(list_valid)))

    valid(list_valid)
    L.info(inference(list_valid[0].nnl_image))


if __name__ == "__main__":
    main()

# [EOF]

Webサービスに組み込んでみた例(NNC版)

# ------------------------------------------------------------------ import(s)
import sys
import os
import hashlib
import io
import time

# -------------------------------------------------------------------- bottle
import bottle
import numpy as np
import PIL.Image
import cv2

# -------------------------------------------------------------------- nnabla
import nnabla as nn
import nnabla.logger as L
import nnabla.utils as U
import nnabla.utils.image_utils
import nnabla.utils.nnp_graph

import vividface_nnl

IMAGE_W = vividface_nnl.IMAGE_W
IMAGE_H = vividface_nnl.IMAGE_H
IMAGE_D = vividface_nnl.IMAGE_D
TARGET_CHARA = vividface_nnl.TARGET_CHARA

MODEL_PATHNAME = "vividface_nnc_model/model.nnp"

EDGE_COLOR = (255, 255, 255)
LINE_COLOR = (0, 0, 255)
IMWORK_EXPIRE_SEC = 1 * 60 * 60
IMWORK_DIR = "./imwork"


# ------------------------------------------------------------------- class(s)
# ---------------------------------------------------------------- function(s)
def imwork_clean(expire_sec):

    current_time = time.time()

    for dir_name, _, list_filename in os.walk(IMWORK_DIR):
        for filename in list_filename:
            if os.path.splitext(filename)[1] in (".png", ".jpg", ".jpeg"):
                pathname = os.path.join(dir_name, filename)
                oss = os.stat(pathname)
                if (current_time - oss.st_mtime) > expire_sec:
                    os.remove(pathname)


@bottle.route("/")
def html_index():
    return bottle.template("index")


@bottle.route("/imwork/<img_filepath:path>", name="imwork")
def res_image(img_filepath):
    return bottle.static_file(img_filepath, root=IMWORK_DIR)


@bottle.route("/decide")
def html_decide():
    bottle.redirect("/")


@bottle.route("/decide", method="POST")
def do_upload():
    try:
        upload = bottle.request.files.get("upload", "")
        if os.path.splitext(upload.filename)[1].lower() not in (".png", ".jpg",
                                                                ".jpeg"):
            bottle.redirect("/")
    except AttributeError:
        bottle.redirect("/")

    data_raw = upload.file.read()

    image_hash = hashlib.sha1(data_raw).hexdigest()

    data_pil = PIL.Image.open(io.BytesIO(data_raw))
    if data_pil.mode != "RGB":
        data_pil = data_pil.convert("RGB")

    clip_cv2 = cv2.cvtColor(np.asarray(data_pil), cv2.COLOR_RGB2BGR)
    data_cv2 = cv2.cvtColor(np.asarray(data_pil), cv2.COLOR_RGB2BGR)

    cv2_cascade = cv2.CascadeClassifier("lbpcascade_animeface.xml")

    gry = cv2.cvtColor(data_cv2, cv2.COLOR_BGR2GRAY)
    gry = cv2.equalizeHist(gry)

    list_face = cv2_cascade.detectMultiScale(gry,
                                             scaleFactor=1.1,
                                             minNeighbors=5,
                                             minSize=(IMAGE_W, IMAGE_H))

    nn.clear_parameters()
    nnc_nnp = U.nnp_graph.NnpLoader(MODEL_PATHNAME)
    nnc_net = nnc_nnp.get_network("MainRuntime", 1)
    net_x = nnc_net.inputs["Input"]
    net_y = nnc_net.outputs["Softmax"]

    list_result = []
    for idx, tpl_region in enumerate(list_face):
        ix, iy, iw, ih = tpl_region

        o_ref = cv2.cvtColor(clip_cv2[iy:iy + ih, ix:ix + iw],
                             cv2.COLOR_BGR2RGB)
        o_ref = U.image_utils.imresize(o_ref, (IMAGE_W, IMAGE_H))

        face_pathname = os.path.join(IMWORK_DIR,
                                     "%s_%02d.png" % (image_hash, idx))
        U.image_utils.imsave(face_pathname, o_ref)

        net_x.d = [(o_ref.transpose(2, 0, 1) / 255)]
        net_y.forward()
        list_detect_rate = net_y.d[0].tolist()

        rate_max = max(list_detect_rate)
        n_idx = list_detect_rate.index(rate_max)
        chracter_name = TARGET_CHARA[n_idx]

        cv2.rectangle(data_cv2, (ix, iy), (ix + iw, iy + ih), EDGE_COLOR, 3)
        cv2.rectangle(data_cv2, (ix, iy), (ix + iw, iy + ih), LINE_COLOR, 1)
        for txt_y in (-2, -1, 0, 1, 2):
            for txt_x in (-2, -1, 0, 1, 2):
                cv2.putText(data_cv2, "%d) %s" % (idx, chracter_name),
                            (ix + 3 + txt_x, iy + ih + 16 + txt_y),
                            cv2.FONT_HERSHEY_DUPLEX, 0.5, EDGE_COLOR)
        cv2.putText(data_cv2, "%d) %s" % (idx, chracter_name),
                    (ix + 3, iy + ih + 16), cv2.FONT_HERSHEY_DUPLEX, 0.5,
                    LINE_COLOR)

        list_result.append([face_pathname, chracter_name, list_detect_rate])

    imwork_clean(IMWORK_EXPIRE_SEC)

    pathname = os.path.join(IMWORK_DIR, "%s.jpg" % (image_hash, ))
    cv2.imwrite(pathname, data_cv2)

    return bottle.template("index", pathname=pathname, list_result=list_result)


if __name__ == "__main__":
    bottle.run(host="localhost", port=8001, debug=True, reloader=True)

# [EOF]

NNCを介さず、NNLだけで実現する例

NNCを使用せずにNNLだけで記述すると、以下の様になります。

# ------------------------------------------------------------------ import(s)
import sys
import os
import random
import csv

import numpy as np

# -------------------------------------------------------------------- nnabla
import nnabla as nn
import nnabla.functions as F
import nnabla.parametric_functions as PF
import nnabla.solvers as S
import nnabla.logger as L
import nnabla.utils as U
import nnabla.utils.image_utils

try:
    import nnabla.ext_utils
    ctx = nnabla.ext_utils.get_extension_context("cudnn")
    nn.set_default_context(ctx)
except:
    pass

TARGET_CHARA = {
    0: "corona",
    1: "einhald",
    2: "fuka",
    3: "miura",
    4: "rinne",
    5: "rio",
    6: "vivio"
}

DATASET_DIR = "./dataset"
IMAGE_W = 48
IMAGE_H = 48
IMAGE_D = 3
LOAD_SIZE = 100
BATCH_SIZE = len(TARGET_CHARA) * 20

VIVIDFACE_TRAIN = "vividface_train_%dx%d" % (IMAGE_W, IMAGE_H)
VIVIDFACE_VALID = "vividface_valid_%dx%d" % (IMAGE_W, IMAGE_H)


# ------------------------------------------------------------------- class(s)
class CDataset(object):
    def __init__(self, chara_name, filename, nnl_image, nnl_onehot):
        self.chara_name = chara_name
        self.filename = filename
        self.nnl_image = nnl_image
        self.nnl_onehot = nnl_onehot


# ---------------------------------------------------------------- function(s)
def build(in_x, in_y, train=True):

    if train is True:
        h = F.image_augmentation(in_x, (IMAGE_D, IMAGE_W, IMAGE_H), (0, 0),
                                 1.0, 1.0, 0.0, 1.0, 0.0, False, False, 0.0,
                                 False, 1.1, 0.5, False, 0.0, 0)
    else:
        h = F.image_augmentation(in_x)

    with nn.parameter_scope("conv1"):
        h = PF.convolution(h, 8, (2, 2), stride=(2, 2), pad=(0, 0))
        h = PF.batch_normalization(h,
                                   axes=(1, ),
                                   decay_rate=0.9,
                                   eps=0.0001,
                                   batch_stat=train)
        h = F.relu(h, True)

    with nn.parameter_scope("conv2"):
        h = PF.convolution(h, 16, (2, 2), stride=(2, 2), pad=(0, 0))
        h = PF.batch_normalization(h,
                                   axes=(1, ),
                                   decay_rate=0.9,
                                   eps=0.0001,
                                   batch_stat=train)
        h = F.relu(h, True)

    with nn.parameter_scope("conv3"):
        h = PF.convolution(h, 64, (3, 3), stride=(2, 2), pad=(1, 1))
        h = PF.batch_normalization(h,
                                   axes=(1, ),
                                   decay_rate=0.9,
                                   eps=0.0001,
                                   batch_stat=train)
        h = F.relu(h, True)

    with nn.parameter_scope("affine4"):
        h = PF.affine(h, len(TARGET_CHARA) * 20)
        h = F.relu(h, True)

    with nn.parameter_scope("affine5"):
        h = PF.affine(h, len(TARGET_CHARA) * 10)
        h = F.relu(h, True)

    with nn.parameter_scope("affine6"):
        h = PF.affine(h, len(TARGET_CHARA) * 3)
        h = F.relu(h, True)

    with nn.parameter_scope("affine7"):
        h = PF.affine(h, len(TARGET_CHARA))
        h = F.softmax(h)

    return h


def train(list_train, list_valid, epoch_limit=10000):

    x = nn.Variable(shape=(BATCH_SIZE, IMAGE_D, IMAGE_W, IMAGE_H))
    y = nn.Variable(shape=(BATCH_SIZE, len(TARGET_CHARA)))
    f = build(x, None)

    h = F.squared_error(f, y)

    loss = F.mean(h)

    solver = S.Adam()
    solver.set_parameters(nn.get_parameters())

    for _ in range(10):
        random.shuffle(list_train)

    epoch = 1
    while True:

        for n in range(0, len(list_train), BATCH_SIZE):

            x.d = [o.nnl_image for o in list_train[n:n + BATCH_SIZE]]
            y.d = [o.nnl_onehot for o in list_train[n:n + BATCH_SIZE]]

            loss.forward()
            solver.zero_grad()
            loss.backward()
            solver.update()

        if (epoch % 10) == 0:
            list_result = valid(list_valid)
            min_score = min(list_result)
            L.info("epoch(s): [%6d]  score: [%.3f] loss: [%.12f]" %
                   (epoch, min_score, loss.d))
            if min_score > 0.65:
                model_filename = "vividface_nnl_model/train_model_%06d_%03d_%6f.h5" % (
                    epoch, int(min_score * 100), loss.d)
                nn.save_parameters(model_filename)

        epoch += 1
        if epoch_limit > 0:
            if epoch > epoch_limit:
                break


def valid(list_valid):

    x = nn.Variable(shape=(len(list_valid), IMAGE_D, IMAGE_W, IMAGE_H))
    f = build(x, None, False)

    list_size = [0] * len(TARGET_CHARA)
    list_true = [0] * len(TARGET_CHARA)
    list_ans = [0] * len(TARGET_CHARA)

    list_x = [o.nnl_image for o in list_valid]
    list_y = [o.nnl_onehot for o in list_valid]

    x.d = list_x

    f.forward()

    for y, result in zip(list_y, f.d):
        v_max = max(result)
        n_idx = result.tolist().index(v_max)
        list_onehot = [0] * len(TARGET_CHARA)
        list_onehot[n_idx] = 1

        list_size[y.index(1)] += 1
        list_ans[n_idx] += 1
        if list_onehot == y:
            list_true[n_idx] += 1

    list_result = []
    for size, true in zip(list_size, list_true):
        if size > 0:
            list_result.append(true / size)
        else:
            list_result.append(0)

    L.info("size     " + " ".join(["%6.2f" % v for v in list_size]))
    L.info("true     " + " ".join(["%6.2f" % v for v in list_true]))
    L.info("ans      " + " ".join(["%6.2f" % v for v in list_ans]))
    L.info("result   " + " ".join(["%6.2f" % v for v in list_result]))

    return list_result


def inference(nnl_image):

    x = nn.Variable(shape=(1, IMAGE_D, IMAGE_W, IMAGE_H))
    x.d = [nnl_image]

    f = build(x, None, False)
    f.forward()

    return f.d[0].tolist()


def dataset_save(dir_name, basename, list_data):

    csv_file = os.path.join(dir_name, basename) + ".csv"
    print(csv_file)

    with open(csv_file, "w") as hw:
        csv_w = csv.writer(hw)
        csv_w.writerow(["x"] + [
            "y__%d:%s" % (n, TARGET_CHARA[n]) for n in range(len(TARGET_CHARA))
        ])
        for o in list_data:
            csv_w.writerow(["./%s/%s" % (o.chara_name, o.filename)] +
                           o.nnl_onehot)


def main():

    list_train = []
    list_valid = []

    for dir_name, _, list_filename in os.walk(DATASET_DIR):
        _, chara_name = os.path.split(dir_name)
        if chara_name in TARGET_CHARA.values():
            list_image = []
            for filename in list_filename:
                if os.path.splitext(filename)[1].lower() in (".png", ".jpg",
                                                             ".jpeg"):

                    nnl_image = U.image_utils.imread(os.path.join(
                        dir_name, filename),
                                                     size=(IMAGE_W, IMAGE_H),
                                                     channel_first=False)

                    nnl_image = nnl_image.transpose(2, 0, 1)

                    list_onehot = [0] * len(TARGET_CHARA)
                    dict_chara = {v: k for k, v in TARGET_CHARA.items()}
                    list_onehot[dict_chara[chara_name]] = 1

                    list_image.append(
                        CDataset(chara_name, filename, nnl_image / 255.0,
                                 list_onehot))

                if len(list_image) == LOAD_SIZE:
                    break

            list_train += list_image[0:-20]
            list_valid += list_image[-20:]

            L.info("%s %4d %4d" %
                   (chara_name, len(list_train), len(list_valid)))

    dataset_save(DATASET_DIR, VIVIDFACE_TRAIN, list_train)
    dataset_save(DATASET_DIR, VIVIDFACE_VALID, list_valid)

    train(list_train, list_valid)


if __name__ == "__main__":
    main()

# [EOF]

Webサービスに組み込んでみた例(NNL版)

# ------------------------------------------------------------------ import(s)
import sys
import os
import hashlib
import io
import time

# -------------------------------------------------------------------- bottle
import bottle
import numpy as np
import PIL.Image
import cv2

# -------------------------------------------------------------------- nnabla
import nnabla as nn
import nnabla.logger as L
import nnabla.utils as U
import nnabla.utils.image_utils
import nnabla.utils.nnp_graph

import vividface_nnl

IMAGE_W = vividface_nnl.IMAGE_W
IMAGE_H = vividface_nnl.IMAGE_H
IMAGE_D = vividface_nnl.IMAGE_D
TARGET_CHARA = vividface_nnl.TARGET_CHARA

MODEL_PATHNAME = "vividface_nnc_model/model.nnp"

EDGE_COLOR = (255, 255, 255)
LINE_COLOR = (0, 0, 255)
IMWORK_EXPIRE_SEC = 1 * 60 * 60
IMWORK_DIR = "./imwork"


# ------------------------------------------------------------------- class(s)
# ---------------------------------------------------------------- function(s)
def imwork_clean(expire_sec):

    current_time = time.time()

    for dir_name, _, list_filename in os.walk(IMWORK_DIR):
        for filename in list_filename:
            if os.path.splitext(filename)[1] in (".png", ".jpg", ".jpeg"):
                pathname = os.path.join(dir_name, filename)
                oss = os.stat(pathname)
                if (current_time - oss.st_mtime) > expire_sec:
                    os.remove(pathname)


@bottle.route("/")
def html_index():
    return bottle.template("index")


@bottle.route("/imwork/<img_filepath:path>", name="imwork")
def res_image(img_filepath):
    return bottle.static_file(img_filepath, root=IMWORK_DIR)


@bottle.route("/decide")
def html_decide():
    bottle.redirect("/")


@bottle.route("/decide", method="POST")
def do_upload():
    try:
        upload = bottle.request.files.get("upload", "")
        if os.path.splitext(upload.filename)[1].lower() not in (".png", ".jpg",
                                                                ".jpeg"):
            bottle.redirect("/")
    except AttributeError:
        bottle.redirect("/")

    data_raw = upload.file.read()

    image_hash = hashlib.sha1(data_raw).hexdigest()

    data_pil = PIL.Image.open(io.BytesIO(data_raw))
    if data_pil.mode != "RGB":
        data_pil = data_pil.convert("RGB")

    clip_cv2 = cv2.cvtColor(np.asarray(data_pil), cv2.COLOR_RGB2BGR)
    data_cv2 = cv2.cvtColor(np.asarray(data_pil), cv2.COLOR_RGB2BGR)

    cv2_cascade = cv2.CascadeClassifier("lbpcascade_animeface.xml")

    gry = cv2.cvtColor(data_cv2, cv2.COLOR_BGR2GRAY)
    gry = cv2.equalizeHist(gry)

    list_face = cv2_cascade.detectMultiScale(gry,
                                             scaleFactor=1.1,
                                             minNeighbors=5,
                                             minSize=(IMAGE_W, IMAGE_H))

    nn.clear_parameters()
    nnc_nnp = U.nnp_graph.NnpLoader(MODEL_PATHNAME)
    nnc_net = nnc_nnp.get_network("MainRuntime", 1)
    net_x = nnc_net.inputs["Input"]
    net_y = nnc_net.outputs["Softmax"]

    list_result = []
    for idx, tpl_region in enumerate(list_face):
        ix, iy, iw, ih = tpl_region

        o_ref = cv2.cvtColor(clip_cv2[iy:iy + ih, ix:ix + iw],
                             cv2.COLOR_BGR2RGB)
        o_ref = U.image_utils.imresize(o_ref, (IMAGE_W, IMAGE_H))

        face_pathname = os.path.join(IMWORK_DIR,
                                     "%s_%02d.png" % (image_hash, idx))
        U.image_utils.imsave(face_pathname, o_ref)

        net_x.d = [(o_ref.transpose(2, 0, 1) / 255)]
        net_y.forward()
        list_detect_rate = net_y.d[0].tolist()

        rate_max = max(list_detect_rate)
        n_idx = list_detect_rate.index(rate_max)
        chracter_name = TARGET_CHARA[n_idx]

        cv2.rectangle(data_cv2, (ix, iy), (ix + iw, iy + ih), EDGE_COLOR, 3)
        cv2.rectangle(data_cv2, (ix, iy), (ix + iw, iy + ih), LINE_COLOR, 1)
        for txt_y in (-2, -1, 0, 1, 2):
            for txt_x in (-2, -1, 0, 1, 2):
                cv2.putText(data_cv2, "%d) %s" % (idx, chracter_name),
                            (ix + 3 + txt_x, iy + ih + 16 + txt_y),
                            cv2.FONT_HERSHEY_DUPLEX, 0.5, EDGE_COLOR)
        cv2.putText(data_cv2, "%d) %s" % (idx, chracter_name),
                    (ix + 3, iy + ih + 16), cv2.FONT_HERSHEY_DUPLEX, 0.5,
                    LINE_COLOR)

        list_result.append([face_pathname, chracter_name, list_detect_rate])

    imwork_clean(IMWORK_EXPIRE_SEC)

    pathname = os.path.join(IMWORK_DIR, "%s.jpg" % (image_hash, ))
    cv2.imwrite(pathname, data_cv2)

    return bottle.template("index", pathname=pathname, list_result=list_result)


if __name__ == "__main__":
    bottle.run(host="localhost", port=8001, debug=True, reloader=True)

# [EOF]

画像ライブラリの相互変換

Pythonで画像を扱う際はPillowやOpenCV2を使うことが多いのですが、nnablaのimage_utilsを使うことが増えたので、相互変換する際のメモを。

import sys
# Pillow
import PIL
import PIL.Image
# OpenCV
import cv2
import numpy as np
# Neural Network Library
import nnabla.utils.image_utils


def main():

    # ------------------------------------------------------ nnabla to nnabla
    print("nnabla")

    o_nnl_image = nnabla.utils.image_utils.imload(sys.argv[1])
    print(type(o_nnl_image), o_nnl_image.shape)  # type, (h, w, c)
    nnabla.utils.image_utils.imsave("test_nnl_to_nnl.png", o_nnl_image)

    # nnabla to OpenCV
    # RGB to BGR
    # o_nnl_image[:, :, ::-1].copy()
    o_cv2_image = cv2.cvtColor(o_nnl_image, cv2.COLOR_BGR2RGB)
    print(type(o_cv2_image), o_cv2_image.shape)  # type, (h, w, c)
    cv2.imwrite("test_nnl_to_cv2.png", o_cv2_image)

    # nnabla to Pillow
    o_pil_image = PIL.Image.fromarray(o_nnl_image)
    print(type(o_pil_image), o_pil_image.size,
          o_pil_image.mode)  # type, w, h, mode
    o_pil_image.save("test_nnl_to_pil.png")

    # ------------------------------------------------------ Pillow to Pillow
    print("Pillow")

    o_pil_image = PIL.Image.open(sys.argv[1])
    print(type(o_pil_image), o_pil_image.size,
          o_pil_image.mode)  # type, w, h, mode
    o_pil_image.save("test_pil_to_pil.png")

    # Pillow to OpenCV
    # PI.Image.Image to numpy.ndarray
    o_image = np.asarray(o_pil_image)
    # RGB to BGR
    # o_cv2_image = o_image[:, :, ::-1].copy()
    o_cv2_image = cv2.cvtColor(o_nnl_image, cv2.COLOR_RGB2BGR)

    print(type(o_cv2_image), o_cv2_image.shape)  # type, (h, w, c)
    cv2.imwrite("test_pil_to_cv2.png", o_cv2_image)

    # Pillow to nnabla
    o_nnl_image = np.asarray(o_pil_image)
    print(type(o_nnl_image), o_nnl_image.shape)  # type, (h, w, c)
    nnabla.utils.image_utils.imsave("test_pil_to_nnl.png", o_nnl_image)

    # ------------------------------------------------------ OpenCV to OpenCV
    print("OpenCV")

    o_cv2_image = cv2.imread(sys.argv[1])
    print(type(o_cv2_image), o_cv2_image.shape)  # type, (h, w, c)
    cv2.imwrite("test_cv2_to_cv2.png", o_cv2_image)

    # OpenCV to nnabla
    # BGR to RGB
    # o_nnl_image = o_cv2_image[:, :, ::-1].copy()
    o_nnl_image = cv2.cvtColor(o_cv2_image, cv2.COLOR_BGR2RGB)
    print(type(o_nnl_image), o_nnl_image.shape)  # type, (h, w, c)
    nnabla.utils.image_utils.imsave("test_cv2_to_nnl.png", o_nnl_image)

    # OpenCV to Pillow
    # BGR to RGB
    # o_image = o_cv2_image[:, :, ::-1].copy()
    o_image = cv2.cvtColor(o_cv2_image, cv2.COLOR_BGR2RGB)
    # numpy.ndarray to PIL.Image.Image
    o_pil_image = PIL.Image.fromarray(o_image)
    print(type(o_pil_image), o_pil_image.size,
          o_pil_image.mode)  # type, w, h, mode
    o_pil_image.save("test_cv2_to_pil.png")


if __name__ == "__main__":
    main()

nnablaのサンプルコード

SONYのニューラルネットワークライブラリhttps://nnabla.org/ja/について、メモなんぞを。

学習内容は 0.0の時は0.9、0.1の時は0.8…といった感じでy = 0.9 – xをかえすだけの動作です。

サンプルなので、学習と評価データはプログラムに埋め込んであります。

import sys

import numpy as np

import nnabla as nn
import nnabla.functions as F
import nnabla.parametric_functions as PF
import nnabla.solvers as S
import nnabla.logger as L

BATCH_SIZE = 10
LIST_X = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
LIST_Y = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0]


def build(in_x, in_y):
    """ニューラルネットワークの作成
    """

    with nn.parameter_scope("affine1"):
        h = PF.affine(in_x, 2)
        h = F.relu(h)

    with nn.parameter_scope("affine2"):
        h = PF.affine(h, 2)
        h = F.relu(h)

    with nn.parameter_scope("affine3"):
        h = PF.affine(h, 1)
        f = F.relu(h)

    return f


def train(f, in_x, in_y, epoch=1000, modelfile="model.h5"):
    """作成したネットワークfをepoch回学習して、学習結果をmodelfile名で保存
    """

    h = F.squared_error(f, in_y)

    loss = F.mean(h)

    solver = S.Adam()
    solver.set_parameters(nn.get_parameters())

    for n in range(epoch):

        in_x.d = np.reshape(np.array(LIST_X), (BATCH_SIZE, 1))
        in_y.d = np.reshape(np.array(LIST_Y), (BATCH_SIZE, 1))

        loss.forward()
        solver.zero_grad()
        loss.backward()
        solver.update()

        if n % 10 == 0:
            L.info("%8d : %0.2f" % (n, loss.d))

    nn.save_parameters(modelfile)


def inference(f, in_x, in_y, modelfile="model.h5"):
    """学習結果を保存したmodelfile名をネットワークに適用して、推論を行う
    """

    nn.load_parameters(modelfile)

    for v in LIST_X:
        in_x.d = np.reshape(np.array([v]), (1, 1))
        f.forward()
        L.info("%0.1f = %0.1f" % (v, f.d[0]))


def main():

    x = nn.Variable(shape=(BATCH_SIZE, 1))
    y = nn.Variable(shape=(BATCH_SIZE, 1))

    f = build(x, y)
    train(f, x, y)
    inference(f, x, y)


if __name__ == "__main__":
    main()

# [EOF]

3Dで樹木の描画

今までにも何度かチャレンジしていますが、今回はLive Home 3D Proを使ってみました。

レイヤーで重ねて使用するのが前提なので、個々の形状はかなり適当です。

Kubernete 1.17.2 の環境構築メモ

自宅のiMac上にKubernetesの環境を作ってみたときの覚え書き。

使用した環境

  • iMac (Retina 5K, 27-inch, 2017)
    • macOS Catalina 10.15.2 (16GB)
  • VMWare Fusion Pro 11.5.1
    • GuestOS: Ubuntu Server 18.04

独自の仮想ネットワークを作成する場合、VMWare FusionだとPro版が必要となります。VMWare Fusion以外だとVirtualBoxが仮想ネットワークの作成が行えます。

想定しているネットワーク構成はこんな感じ。

Kube(node1)のNICが片方しかないのは、Kubernetesの内部ルーティングの動作を確認したい為です。

Linux自体の環境設定

たいしたことはしていないのですが、ネットワーク設定をしておきます。

まず、VMWareは標準でDHCPによる割り振りがされてしまう為、VM内のホストにはそれぞれ固定IPアドレスを設定しておきます。

  • master
    • ens33: DHCP (ex: 172.16.42.131)
    • ens34: 192.168.110.128
  • node1
    • ens33: 192.168.110.129
  • node2
    • ens33: DHCP (ex: 172.16.42.132)
    • ens34: 192.168.110.130
# Kube(node01)の例
network:
    version: 2
    ethernets:
        ens33:
            dhcp4: false
            addresses:
                - 192.168.110.129/24
            gateway4:
                192.168.110.128
            nameservers:
                addresses: [172.16.42.2]

master側

masterはクラスタのルーティングを兼ねている為、以下の設定をしておきます。これをやらないとnode1は外部と通信が出来ません。

sudo iptables -A FORWARD -i ens33 -j ACCEPT
sudo iptables -A POSTROUTING -t nat -j MASQUERADE -s 192.168.110.0/24

保存する場合は aptitude install iptables-persistent で、netfilter-persistent を導入することで保存が出来るようになります。

netfilter-persistent save で保存が出来るようになります。

この時点でのiptablesの設定はこんな感じです。Docker関係の項目はDockerが起動する際に追加される項目です。

# Generated by iptables-save v1.6.1
*filter
:INPUT ACCEPT [472:50670]
:FORWARD ACCEPT [476:28584]
:OUTPUT ACCEPT [290:35406]
:DOCKER - [0:0]
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A FORWARD -i ens33 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
COMMIT
# Completed

# Generated by iptables-save v1.6.1
*nat
:PREROUTING ACCEPT [458:27778]
:INPUT ACCEPT [1:334]
:OUTPUT ACCEPT [12:929]
:POSTROUTING ACCEPT [12:929]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 192.168.110.0/24 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed

Kubernetes 1.17.2のセットアップ

セットアップはubuntu 18.4環境にDockerをインストールし、kubeadmで行います。この方法は時代遅れなのかもしれませんが、今のところ自分が慣れているのがこの方法ですので、以下のマニュアルに倣って行います。

ubuntu 18.04であれば、記載されている手順通りに行えばセットアップは完了します。

ubuntu18.04では標準でiptablesがインストールされているのですが、それ以外のシステムを使用している場合はインストールが行えません。また、ubuntu 18以降のバージョンにおいても iptables とは別のシステムとなる予定だそうです。
これについては、インストールマニュアルの注意を読んでください。

すべてのノードにDocker、kubeadm, kubectl, kubeletのインストールが完了したら、Kubernetesのmasterをセットアップします。

自分は以下のコマンドで行いました。

kubeadm init --apiserver-advertise-address=192.168.110.128 --pod-network-cidr=192.168.0.0/24

ここで –apiserver-advertise-address を指定していますが、これは誤ったNICをAPIServerにしない為です。

NICが複数存在している場合、意図しないものがKubernetesのAPI Server向けに設定されてしまう事がある為、ここでは –apiserver-advertise-address で明示的に指定します。

セットアップが完了すると、設定ファイルをコピーする様に促されますので指示通りに保存します。

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

設定ファイルをコピーしたら kubectl get pods -n kube-system で起動状態を確認出来ます。おそらくこのような状態になっています。

NAMESPACE     NAME                                 READY   STATUS    RESTARTS   AGE
kube-system   coredns-6955765f44-fnpv6             0/1     Pending   0          95s
kube-system   coredns-6955765f44-wdh6x             0/1     Pending   0          95s
kube-system   etcd-k8s-master                      1/1     Running   0          90s
kube-system   kube-apiserver-k8s-master            1/1     Running   0          90s
kube-system   kube-controller-manager-k8s-master   1/1     Running   0          90s
kube-system   kube-proxy-vhm6l                     1/1     Running   0          95s
kube-system   kube-scheduler-k8s-master            1/1     Running   0          89s

coredns が Pending 状態になっていますが、これは Kubernetes 内の network 設定が完了しないと開始しないようにマニフェストが記述されている為に生じているので、この時点では正常です。

network設定には幾つかの選択肢がありますが、ここではcalicoを利用します。

calicoのインストール

calicoは環境にあわせて設定を変更する必要がありますので、まずはマニフェストをローカルにダウンロードます。(Kubernetesのマニュアル通りに実行すると正しく動作しません。calicoのドキュメントを読みながら実行するか、以下の様にCIDRを書き換えてください)

# calico.yamlをダウンロード
cd /tmp
curl https://docs.projectcalico.org/v3.8/manifests/calico.yaml -O
# CALICO_IPV4POOL_CIDRの項目を修正
# kubeadm init で指定したCIDRと同じ物を設定
vi calico.yaml
# 設定を反映
kubectl apply -f calico.yaml

再度 kubectl get pods -n kube-system で確認すると、calico関係のpodが追加されて、corednsもRunningに変わっているはずです。

ノードの追加

ノードの追加方法は、以下のコマンドで表示されるtokenを追加したいノード上で行うだけです。

kubeadm token create --print-join-command

手元の環境では、以下の様なコマンドが表示されます。

kubeadm join 192.168.110.128:6443 \
--token rmlcib.hoff0zcbxcsu19pb \
--discovery-token-ca-cert-hash sha256:f58b4c56b983b3df8b4ecdf666ebb12c0a20e0afc28aac9ee7c704568ceb5287

それぞれのノード上でkubeadm joinを行ってからしばらく待つと

NAME         STATUS   ROLES    AGE     VERSION
k8s-master   Ready    master   10m     v1.17.2
k8s-node01   Ready    <none>   4m14s   v1.17.2
k8s-node02   Ready    <none>   2m40s   v1.17.2

すべてのノードが追加されてReady状態となりました。

podをNICに結びつけてみる

これでKubernetesの設定自体は完了しているのですが、ちょっとしたテストをしてみます。

Kube(node01)は、192.168.110.0/24 のネットワークにしか所属していないため、このままではVMWareのホストである iMac と通信が出来ません。

ですが、KubernetesはPODとの通信をする方法が幾つか用意されており、それらの方法を使用すると、外部と通信が出来るようになります。

ここではウェブサーバーを組み込んだPODを起動して、Kube(master)やKube(node02)のNIC経由でアクセスしてみることにします。

ここでは試しにphpinfoを表示するだけのpodを作成してみました。

apiVersion: v1
kind: Pod
metadata:
  name: testweb
  labels:
    component: web
spec:
  containers:
    - name: testweb
      image: php:7.3-apache
      ports:
        - containerPort: 80
      volumeMounts:
        - name: config-volume
          mountPath: /var/www/html
  volumes:
    - name: config-volume
      configMap:
        name: testweb-phpcode
  nodeSelector:
      kubernetes.io/hostname: k8s-node01
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: testweb-phpcode
data:
  index.php: |
    <?php phpinfo(); ?>
---
apiVersion: v1
kind: Service
metadata:
  name: testweb-port
spec:
  selector: 
    component: web
  ports:
    - name: http
      protocol: TCP
      port: 8080
      targetPort: 80
  externalIPs:
    - 172.16.42.131

externalIPsは、Kube(master)かKube(node02)が持っているIPAddressを設定してください。

このマニフェストファイルを、webserver.yamlという名称で保存したら、以下の様にしてKubernetesにpodを立ち上げてください。

kubectl apply -f webserver.yaml

マニフェストファイルに nodeSelector があるため、必ずKube(node01)で起動されます。一見通信が出来ない様にみえますが、 Kubernetes 内でうまくルーティングされて externalIPs にアクセスするとPODにつないでくれます。

上の例ですと、172.16.42.131:8080にブラウザでアクセスすると、phpinfoの画面が開きます。

リセットする場合

kubeadm reset でリセットしてやり直すことが出来ますが、リセットされるのは Kubernetes 本体のみとなります。その他の部分についてはそれぞれ個別にリセットが必要となります。

  • /etc/cni/net.d の削除
  • iptables の初期化(Calicoがiptablesに変更を加えていますが、再起動すれば消えます)
  • modprobe -r ipip(Calicoはtunl0というNICを作成して、ノード間接続を実現しているのですが、このNICは kubeadm resetをしても消えませんので、手動で削除する必要があります)

バーコードスキャナを購入

なんとなくUSBタイプのバーコードスキャナを買ってみました。

バーコードスキャナの実体は単なるUSBキーボードですので、iPad等でもLightning to USBアダプタ経由で利用可能です。

バーコードの生成は様々なソフトウェアで生成出来ますのであまり困ることはありませんが、自分は python-barcode を使用しました。

python-barcodeは様々なバーコードを生成出来ますが、ここではEANを試しています。

import barcode
from barcode.writer import ImageWriter

bc_ean = barcode.get_barcode_class("ean")
bc_ean("200123456789", writer=ImageWriter()).save("filename")

使用できるのは12文字の0-9までの数字となります。

自分で使う分には、好き勝手な番号を設定すれば良いのですが、JANコード等のルールに沿うと先頭は国コードを入れる事になっています。

日本の国コードとしては450〜459, 490〜499が割り当てられており、実際には国コードを含めて7桁か9桁の企業コードとして利用されています。

ルールに沿うと、国内において好きな数値を利用するには、先頭を20〜29までの範囲の数値にする事で、インストアマーキングとして利用可能です。

というわけで、使用できるのは12桁中10桁の範囲となります。

印刷サイズについて

バーコードは印刷サイズに制限があり、一番細い幅が0.33mmという決まりがあり、縮小したくても 0.8倍率までという決まりがあります。

手元のレーザープリンタで出力させたものを読み込んでみたところ、全体の幅が20mmを切るとかなり読み取りが怪しくなりました。

Nyan!ハルにゃん?の公開

頒布物の配置が悪かったり告知不足だった事もあり、読めなかったという声もありましたので、リリカルマジカル28で無料頒布した「Nyan!ハルにゃん?」を公開しました。

絵は同じですが、当日頒布したものからすこし文章を手直ししました。

物語は一緒ですが文体がバラバラでしたので、なるべく同じような文体に揃えました。

既にお持ちの方は、手元のものとの違いを楽しんでみたり、電子ブック変わりに保存してみたり(?)とご利用下さい。

頒布終了物のページから閲覧可能です。

イベントお疲れ様でした

リリカルマジカル28に参加された皆さん、お疲れ様でした。

今回は新刊がなかったのですが、それにも関わらずスペースに来てくれる方もいまして、嬉しいやら恥ずかしいやら…といった感じでした。

終わった後にお品書きを出してもしょうも無い気がしますが、当日のお品書きは以下の様なものでした。

今年はリリマジ以外のイベントにも参加してみました。

頑張ってみたもののイベントラッシュに自分の執筆速度がついていけませんでした。二種類のお品書きのうち右側がソードアート・オンライン関係になっているのはその名残(?)だったりします。

新刊について

新刊は用意出来なかった…というよりも間に合わなかったのですが、サークルカットで描いた、マスターとデバイスが入れ替わっちゃう話をコピー誌として作成しました。
サークルカットでは、アインハルト <-> ウラカンだったのですが、実際にはアインハルト <-> アスティオンとなりました。

アインハルトさんとティオの不思議な体験を楽しんで貰えれば嬉しいです。今回は無償配布だったのと、話を読んでみたいという方がいましたので、手直し後に公開する予定です。

差し入れありがとうございます

リリカルマジカル11から水凪工房としてイベントに参加しはじめた時、本を描いて、ましてや差し入れを頂けてしまうとは…

今回も何人かに差し入れを頂いたり、お菓子を交換しあったりしました。実は当日、朝食を食べていなかったのもあり、早速食べてしまったものもあったりします。

もの凄く嬉しいけど、太らないように気をつけないと…

今回のイベントは新刊が出すことは出来ませんでしたが、それでも楽しい時間を過ごすことが出来ました。

次回はちゃんと本をだすぞ〜!!!

イベントお疲れ様でした

スーパーヒロインタイム2019秋に参加された皆さん、お疲れ様でした。
水凪工房は、先週(みみけっと)、今週(スーパーヒロインタイム)と、連続でイベントに参加し、無事乗り切ることが出来ました。

今回もSAO枠で参加しました。SAOでGGOでフェイタルバレットという、だいぶ脇道なシリーズを扱っています。

フェイタルバレットは本編と異なる時間軸を進んでいるのが、それはそれで嬉しかったりして。(ユウキがわりと元気だったり、ユナやエイジ、アリス、ユージオ等が普通に同居している)

前回は、フェイタルバレットの登場キャラクター達に他作品の服装を着てもらう本でしたが、今回はGGOのレンちゃんだけに絞り込んだ本にしてみました。

イベント感想

自分の本はマンガでもイラスト集でもなく、レンちゃんがかわいい服を着て(あわよくば)スクショを撮るといった、謎コンセプト溢れる内容でしたが、思った以上に本を手に取ってくれる方がいました。

こんなにレンちゃん好きがいたなんて…(勘違い)

「きがえてレンちゃん 素敵なコーデ!」について

ところで今回殆どの人に「この値段設定大丈夫ですか?」と心配されました。なんとわりと大丈夫ではなかったりします(笑)

毎度ながら執筆が遅い為、コピー誌による頒布をしていたところギリギリで印刷が間に合ってしまいました。ところが既にコピー誌での頒布と値段を告知してしまった後だった為、値段はそのままにしました。

でも、レンちゃんがもっと好きになる → GGO好きが増える → 原作小説やマンガ、アニメが売れまくる → GGOの二期が放映される → フェイタルバレットの続編が発売される。となれば問題なしですね。

それにしてもおまけで描いたアリス(画像は加工前のもの)、アニメを観ている人だと誰だか判らないと思うのですけど、フェイタルバレットのアリスは軍服の様な格好をしていてかっこよかったりします。

レンちゃんが自分のホームで活躍できるゲームは、確かにフェイタルバレットだけなのですけど、思ったより遊んでいる人がいて驚き。

まぁ私はXBOX One版なんですけど…w

スケブについて

今回もスケブ依頼を頂いたのですが、どう頑張ってもデジタルでしか描けないためお断り致しました。絵を描いて欲しいという依頼がくるというのは、とても嬉しいです。

スケブは無理でもなるべくアナログ感溢れるイラストを渡せないかと考えて、今回実験的に描いてみた物。

もう少し線を細めにして、水彩かマーカーで塗ったら良い感じになる様な気がする。

ポスターについて

今回はこんなのを作ってみました。

リリマジだと新刊をタペストリーにして飾って、終了前に抽選を考えていましたが、今回はサークルスペースの看板を兼ねてサークル名やタイトル、スペース番号を入れてしまいました。

こちらはイベント終了後のアフター景品にして貰いました。