Tech系サービスやガジェットの使い心地、自分の作業環境、資産運用について気が向いたときに記録を残しています。

記事内のAmazonアソシエイト適格販売及び、Google Adsenseでお小遣いを得ています。

Withingsの体組成計を買って、GASでSlackの減量チャンネルにポストする

f:id:gensobunya:20191108222621j:plain
withings

ものぐさレコーディングダイエット

減量できたことないけど、いろんな事を記録して可視化するのはいいこと。
そして、体重の崩壊を防ぐ仲間内Slackでは、このようにpostした体重と体脂肪率をグラフ化してくれるスクリプトがすでに動いています。

f:id:gensobunya:20191108222638p:plain
weight graph

f:id:gensobunya:20191108222655p:plain
fatrate graph

postしたusernameを自動的に取得して、それぞれの人の記録をトレンド含め表示してくれています。
今までは、愛用していたTANITAの体組成計から手動でメモしていたのですが、いかんせん朝にメモ作業するのは結構面倒。目も疲れるし。

そこで、Wi-Fi対応の体組成計を購入して、乗ったら自動的にSlackに体重と体脂肪率を飛ばすシステムを作りました。
白羽の矢がたった機種がこちら。

体重の計測は200g刻みと、21世紀の体重計とは思えない精度ですが計測結果が100g程度変動していたところで一喜一憂するものでもなし、インターネット直結という点を重視しました。

とりあえずIFTTT

計測した内容はWithings Health MateというWEBサービスにアップロードされ、モバイルアプリでも閲覧できます。
本体の設定はすべてモバイルアプリ経由なので楽ちん。このサービス自体がIFTTTに対応しているので、まずは直接IFTTTを使ってSlackに連携してみます。

f:id:gensobunya:20191108222807p:plain
ifttt to slack

骨量や筋肉量、水分量も計測できるのですがAPIで取得できるのは「体重」「前回体重差」「体脂肪量」「前回体脂肪量差」「体脂肪率」「計測日時」の6点のみでした。
こちらのIFTTTアプレットの起動結果がこちら。

f:id:gensobunya:20191108222822p:plain
slack post

連携はできましたが、一つ思い出してください。

postしたusernameを自動的に取得して、それぞれの人の記録をトレンド含め表示してくれています。

IFTTTのbotアカウントがpostするだけだとグラフ化の方のbotが正常に動いてくれません。自分のアカウントがpostしたことにしないと…
ちなみにコードを書かなくてもMyfitnessPal経由でGARMIN CONNECTやSTRAVAに体重を連携できるので単体でも結構便利です。

Slack APIを直接叩こう

Slack APIの仕様によると、chat.postMessageas_userオプションをtrueにしてPOSTすればいいようです
IFTTTのオプションにはないので直接何かしらのスクリプトを起動することにします。

f:id:gensobunya:20191108222834p:plain
overview

どこで実行するかが問題ですが、データの受け皿を用意しつつ無料でスクリプトを動かせるとなると、やっぱりGoogle SpreadSheetとApps Scriptがシンプルで簡単です。 tokenの取得やアプリの登録に関しては割愛します。

一回、IFTTTでスプレッドシートにデータを飛ばしてカラムを確認した後に以下のスクリプトを、編集をトリガーに設定して実行します。 Date,Weight,Weight w/o fat,Fat weight,Fat Percentの順に4カラムIFTTTで入力しています。

function getLatestWeightData(){
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const lastRawNum = sheet.getLastRow();
  const dataRange = sheet.getRange(lastRawNum, 1, 1, 5) //Date,Weight,Weight w/o fat,Fat weight,Fat Percent
  const postData = {
    "weightKg": dataRange.getValues()[0][1], 
    "fatPercent": dataRange.getValues()[0][4]
  }
  Logger.log(postData)
  return postData
}


function postMeasureDataToSlack() {
  //Get latest row data
  const data = getLatestWeightData();
  const postMessage = data.weightKg+"kg "+data.fatPercent+"%";
    
  //POST to Slack channel
  const baseURL = "https://slack.com/api/chat.postMessage";
  const postData = {
    "channel": "<your channel name>",
    "as_user": true,
    "text": postMessage
  };
  const options = {
    "method": "post",
    "contentType": "application/json; charset=utf-8",
    "headers":{
      "Authorization": "Bearer <!!your access token!!>"
    },
    "payload": JSON.stringify(postData)
  }
  
  const response = UrlFetchApp.fetch(baseURL, options);
  Logger.log(response)
  return
}

Slackグループの設定がありませんが、トークンがグループごとに発行されるのでそこで認識されています。
"as_user": trueを設定したので、あたかも自分が発言したかのようにチャンネルにポストされます。

f:id:gensobunya:20191108222850p:plain
users post

仲間内で牽制し合うコミュニティではIFTTTよりこちらのほうがより便利かと思います。
他にも自動で有給連絡したり、as_userオプションの使い道は結構ありそうですね。

アプリ連携体重計だとその後の広がりがあまり良くないのですが、WEBサービスだと連携も簡単です。タニタオムロンも、WEBサービス連携する体重計は結構高いので1万円程度で買えるWithingsはかなりコスパ良い買い物でした。

Vue.js + firebase でローカル開発時だけvue devtoolsをfirebase serveと共存させる

Firebase + Vue.jsとかで開発するとき

前提

  • firebaseのバックエンドとVue.jsを同じディレクトリ内で開発している
  • functionsへのアクセスをfirebase.jsonrewriteして使っている
  • Vue-cliなどを使ってプロジェクト作成を丸投げしていた(人)

Vue devtoolsはとても便利です。ただしVueをdevelopモードでビルドしないと発動しません。

バックエンドをfirebase functions等で同じプロジェクト内で開発している場合、大抵の場合firebaseのrewriteを使いたいので、firebase serveでローカル環境にデプロイするんですが、Hostingのディレクトリにビルドしてからfirebaseのローカル環境を立ち上げるとデフォルト設定だとproductionモードでビルドされているのでVue側のデバッグが非常に面倒。

vueとfirebaseでそれぞれserveする選択肢もあるんですが、CORS周りで死ぬ。

ググって5秒で見つけた解決策

Vue.config.devtools = true;をapp.jsとかのエントリーポイントに書く。

デプロイ前だけコメントアウトでもするのか知らないけど、とても事故りそう…

イケてる解決→npmスクリプトの方を触る

npm run serveの中身を書き換えて、developモードでビルドしてそのままfirebaseのローカル環境を立ち上げることにした。

Vue-cli3でプロジェクト作った素の状態がこちら

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },

1コマンドでvueをdevelopでビルドしてローカルfirebaseでホスティングする

  "scripts": {
    "serve": "vue-cli-service build --mode develop & firebase serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },

npm run serveを実行するだけで、vue devtoolsの効くfirebase hosting & functionsのローカル環境が立ち上がる。(もちろん他のサービスを使っている場合はそちらも)嬉しい!

Markdownでオフセット同人誌を刷るまで

sphinx

2022年現在では、VivlioStyleを用いた同人誌作成をやっています。

すぐ書き出すためのテンプレートリポジトリも作成しました。詳細は下記記事にて。

gensobunya-tech.hatenablog.com

電子書籍も紙書籍もキッチリ作りたい!

幻想サイクルの活動では、完全オリジナルであることを活かして現物の同人誌頒布だけでなく、電子媒体での配信も意欲的に行ってきました。

BoothでのPDF版の頒布を皮切りに、Amazon kindle、BookWalker(現在は停止)で配信をはじめ、今ではKindle Unlimitedでも読めるようになっています。

実績から言うと、人気は「紙媒体>>>KindleKindle Unlimited>>>>>>>>>その他」となっています。思ったよりで電子版の配信が好評で驚いています。

しかし…実は、現在配信している同人誌には文字情報がありません。Illustratorで作成している都合上、文字情報を維持したままepubファイル(電子書籍形式)を作成することが手持ちのソフトでは困難だったためです。Boothで配信しているPDFは文字情報を維持できているので、コンテンツの質としてはKindle版より上なのですが利便性はそれに優るようです。
※PDFでもKindleモバイルアプリで読むことは可能です

実用寄りの本を書いていることもあり、せっかくなら文字検索ができる状態の本を出してもらおうとしたのがC95の新刊「泥輪事情」です

技術書典が公表を博していることもあり、1ソースから印刷用のファイルと電子書籍用のファイルを出し分けることに関するノウハウがいくらか溜まっていることは把握していたので、ITを仕事にしているものの端くれとして興味が昂ぶっていたこともあり、原稿ファイルから複数の入稿ファイルをビルドするスタイルに挑戦しました。

以下、検討から出力までかなりの長文が続きますが、最終的に下記のツールでPDFの出力まで持ち込みました。

conf.pyが含まれたリポジトリは下記のとおりです。ビルド方法はREADME参照のこと。

Github : gentksb/md2doujin_sphinx
https://github.com/gentksb/md2doujin_sphinx

プランニング

入稿要件

印刷用の原稿は、同人活動開始以来お世話になっている、ラック出版様の入稿ファイルを要件として設定します。移動体通信事業者のプランがかわいく見える印刷所の料金体系の中で、非常に明快な印刷メニューと割引メニューを準備している素晴らしい印刷所です!(ダイレクトマーケティング

入稿はPDF形式です。要件はこちらのページに記載されている通り、大きく下記の3点です。

  • フォントは全て埋め込み
  • PDF/X-1a形式
  • ページ設定---原寸

出力時に上記を満たしている必要があります。epubは出力してくれればなんとかなる(中身はHTML)なので対して問題にならないだろうと割愛。
技術系同人誌はA5が多いですが、評論かつ弊サークルの前例に従いB5サイズを作成するものとします。

Wordじゃだめなの?

ドキュメントビルドのフレームワークを使うことで、ノンブルの設定や目次の自動生成などの細かいあれこれから開放されるためです。

Wordでもできる?こまけえこたあいいんだよ!あと自宅PCはずいぶん前にGoogleに入信したためOfficeが一切入っていません。あと出先でChromebookを使って執筆したいのでOfficeは(個人的には)一切使いません。

フレームワーク

今回のメインとなるお題です。パッと見た限り、情報と実績が豊富なのは下記の手段。
ソースは使い慣れているMarkdownをメインで使うことを前提としています。このサイトもブログもMarkdownなので、生産性という名前の慣れは抜群。

  1. Pandoc
  2. Sphinx + RecommonMark
  3. Re:View + md2review

ここで、最終的に「LateXを使ってPDFを出力する」という点は逃れられないことに気が付きます。若干の不安を抱えつつもネイティブにMarkdownを扱えるPandocがいいなとこの時点では考えていました。どうしてもMarkdown記法で解決できない問題が出てきた際に、SphinxはReST、Re:ViewRe:View記法を新たに理解する必要があるからです。

新しい記法を覚えるくらいなら原稿に力を注ぎたいので、まずはPandocの参考になる情報を漁り、下記のページに行き当たります。
情報をあさっている間、並行してMarkdownで構造に気を使いつつ原稿を書いていきます。

Pandoc + LaTeX で markdownからA5・縦書・2段組の小説本のPDFを作成 http://adbird.hatenablog.com/entry/2017/01/15/010459

バッチリドンピシャやんけ!!!!!!!

こちらの記事を参考に、章ごとに分割した.mdファイルを準備してビルド!と思うもよく見るとTeXのテンプレートファイルを使ってそこに流し込んでいる=TeXの知識がないと細かいことができない、という点に気が付き候補から脱落。素のビルド内容でも試してみましたが、後述するSphinxに比べあまりにも味気ないので、作り込みの時間をとっていないこともあって残念ながら却下となります。

Re:viewはかなり版組み向けの言語でしたが、それゆえに原稿の可読性が著しく落ちているのでやめておきました。

Sphinx+RecommonMarkでオフセ本に耐えるPDFを出力する

ようやく本題です。Sphinxは優しいのでsphinx-quickstartを実行するだけでいい感じにディレクトリを作ってくれます。 こちらは脱稿時のリポジトリです。

リポジトリ構成

nn_題名.mdが各章の原稿、index.rstがビルドの開始地点です。残りの設定はすべてconf.pyの中。conf_lua.pypLateXではなくluaLateXで出力しようとして失敗したときの名残です。
本来、index.rstの内部でファイルのインポート順を決定できるため、.mdファイルにプレフィックス番号は不要なのですがPandoc用に原稿を作成した名残です。

Markdown利用

まずはSphinxMarkdownを使えるようにします。
RecommonMarkとAutoStructifyをインポートしてググって出てきた内容を追加します。

下記の内容をconf.pyに追記・編集していきます。

import recommonmark
from recommonmark.parser import CommonMarkParser
from recommonmark.transform import AutoStructify

# recommonmark の拡張利用
github_doc_root = 'https://github.com/rtfd/recommonmark/tree/master/doc/'
def setup(app):
    app.add_config_value('recommonmark_config', {
            'url_resolver': lambda url: github_doc_root + url,
            'auto_toc_tree_section': 'Contents',
            }, True)
    app.add_transform(AutoStructify)

次に、.mdファイルを原稿ファイルと認識させます。

# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
source_suffix = ['.rst', '.md']
# source_suffix = '.rst'

source_parsers = {
    '.md' : 'recommonmark.parser.CommonMarkParser'
}

この時点で、ビルドコマンドを打って.mdファイルが原稿で使えるようになりました。
PowerShellだとディレクトリ内に実行ファイルがあっても明示的にパスを指定する必要があるのでめんどいです。

.\make clean
.\make latexpdf

Goodbye、Markdown Table

ここで原稿を見ているとあることに気が付きます。テーブルが出力されていません

RecommonMarkはCommonMarkという標準Markdownのみをサポートしているため、みんな大好きGithub Flavored Markdownでサポートされている表組は対応していません。
残念ですがReST記法を用いたテーブルに書き直す必要がありました。

前節のAutoStructifyは、.mdファイルの中でReST記法を使えるようにしてくれます。下記のようにeval_rstで囲ったコードブロック内をReSTで解釈してくれます。

```eval_rst
============   ==========
出走内容        昇格内容
============   ==========
6人未満         昇格無し
6人以上で1勝    昇格できる
6人以上で2勝    昇格は必須
============   ==========
```

conf.pyでimportするだけでなく、定義も宣言しないと動作しないので注意。

github_doc_root = 'https://github.com/rtfd/recommonmark/tree/master/doc/'
def setup(app):
    app.add_config_value('recommonmark_config', {
            'url_resolver': lambda url: github_doc_root + url,
            'auto_toc_tree_section': 'Contents',
            }, True)
    app.add_transform(AutoStructify)

原稿の見た目はできた、出力形式が問題だ

残念ながら、ここからLateXの設定と格闘することになります。conf_pylatex_elementsの設定内にメタデータJSONライクに書いていくのですが、大半の設定はpreambleというLateXの設定を直書きできる箇所でレイアウトを指定します。

文字サイズ、開き方向、原稿サイズ以外はほぼLateXです。B5サイズを使っている人がなかなか居なかったのですが、'papersize': 'b5j'を指定すれば、LateXの設定抜きでB5サイズで出力できます。

なお、最新版のSphinxではlatex_documentsmanualを指定した場合自動的にLatexのレイアウトがjsbook形式で出力されるので、インターネッツの海に浮かんでいるmanualとjsbookをマッピングさせる設定は不要です。

'extraclassoptions': 'openany,twoside'は本っぽく中央の余白を大きく取るための設定となります。

# -- Options for LaTeX output ------------------------------------------------
latex_engine = 'platex'

latex_elements = {
    'pointsize': '10pt',
    'papersize': 'b5j',
    'extraclassoptions': 'openany,twoside',
    'babel': r'''
\usepackage[japanese]{babel}
''',
    'tableofcontents': r'''
%normalで目次ページを出力しつつSphinxスタイルをやめさせる
\tableofcontents
    ''',
    'preamble': r'''
%フォント
%TexLive側コマンドで制御kanji-config-updmap-user [kozka|kozuka-pr6n|ipaex|yu-win10|status|他]
%luatexで
%\usepackage[kozuka-pr6n]{luatexja-preset}

%pdf/x使うための設定
%\usepackage[x-1a1]{pdfx}

%レイアウト
\renewcommand{\plainifnotempty}{\thispagestyle{plain}}
\setlength{\textheight}{\paperheight}
\setlength{\topmargin}{-5.4truemm}
\addtolength{\topmargin}{-\headheight}
\addtolength{\topmargin}{-\headsep}
\addtolength{\textheight}{-40truemm}
\setlength{\textwidth}{\paperwidth}
\setlength{\oddsidemargin}{-5.4truemm}
\setlength{\evensidemargin}{-5.4truemm}
\addtolength{\textwidth}{-40truemm}

% ハイパーリンクモノクロ
\hypersetup{colorlinks=false}

% 字下げ
%\setlength\parindent{1zw}

% パラグラフ間空白
%\setlength{\parskip}{0pt}

% タイトル装飾
\usepackage{titlesec}
\usepackage{picture}

% chapter
\titleformat{\chapter}[block]
{}{}{0pt}{
  \fontsize{30pt}{30pt}\selectfont\filleft
}[
  \hrule \Large{\filleft 第 \thechapter 章}
]

% section
\titleformat{\section}[block]
{}{}{0pt}
{
  \hspace{0pt}
  \normalfont \Large\bfseries{ \thesection }
  \hspace{-4pt}
}

% subsection
\titleformat{\subsection}[block]
{}{}{0pt}
{
  \hspace{0pt}
  \normalfont \large\bfseries{ \thesubsection }
  \hspace{-4pt}
}

''',
}

# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
#  author, documentclass [howto, manual, or own class]).
latex_documents = [
    ('index', 'c95.tex', '泥輪事情',
    'ゲン', 'manual'),
]

セクションの装飾を入れたところ、目次が「第0章」となってしまいますが消し方がわからないので許容しました。

入稿要件の満たし方

ようやく見た目の整ったPDFが出力されるようになりました。次は入稿ファイルの要件を満たしていきます。

(2020.09) 下記に自分がやったことを記載していますが、今はPDFを変換するツールを公開している方がいるので、楽々変換できます!

github.com

フォント埋め込み

TeX Live2018を使用した場合、デフォルトでPDFにフォントが埋め込まれます。フォント埋め込みに難航している情報が大量に検索に出てきましたが、古い情報です。

IPAフォントをデフォルトで使用していますが、埋め込みフォントはkanji-config-updmap-userというコマンドを利用することで、フォントセットを簡単に切り替えることができます。

これもフォント変更に難航している情報が大量に浮かんでいますが、このコマンドで一発解決します。使えるフォントセットはこちらを参考に。游ゴシック体モリサワAdobeでお馴染みの小塚フォントもインストールされていれば使うことができます。

ページごとのPDF、ノンブル開始番号

実はラック出版では本文全体のPDFではなく、ページごとのPDFを03.pdfの用に単ページごとに入稿する必要があります。が、もちろんSphinxで素直にビルドすると全体のPDFが出てきます。

本文ページは3ページからスタートするため、目次ページのノンブルは3、本文は4ページ目からスタートしますが、Sphinxはデフォルトで目次と本文のノンブルを別物でカウントしていたためこの2つの課題を解決する必要があります。

なぜかconf.pyのpreambleに設定しても動かないため、index.rstに設定を書き込みます。

.. raw:: latex

   %目次は3ページ目から
   \setcounter{page}{3}

.. raw:: latexはこれまたLateX記法をrstに組み込むためのrst記法です。組版を刷る限りLateXからは逃れられないようです。

ページごとのPDF出力には、Sphinxの裏のLateXの裏で動いているdvipdfmxを使います。

そもそも、pLateXによるPDF出力は.tex.dvi.pdfという変換順になっており、副産物としてDVIファイルが生まれています。こいつを横取りして1ページづつビルドし直します。

こんなコマンドをプロジェクトの/_build/latex/で打っていきます。コマンドの大量生成はGoogleスプレッドシートでやりました。

mkdir .\build\latex\indv\
cd .\build\latex\
dvipdfmx -c -V 3 -s 2-2 -o './indv/03.pdf' 'c95.dvi'
dvipdfmx -c -V 3 -s 3-3 -o './indv/04.pdf' 'c95.dvi'

...

これで見た目はフォントが埋め込まれたページごとのPDFが生成されました。

最後の砦、PDF-X形式

入稿にはPDF-X形式が基本です。カラーやらリンクが埋め込まれていないPDFファイルで、印刷の段階で事故が起きないようにするための形式らしい。RGBではなくCMYKになってます。
PDF/X-1a形式で出力するためとりあえずTeX Wikiへ。

dvipdfmx には出力結果が PDF/X 準拠かどうか検証したり,準拠するように適切に処理したりする機能はありません。

……………………………

LuaLatexはdvildfmxを使わずにPDFを出力でき、\usepackage[x-1a1]{pdfx}をプリアンブルに入れることでpdf-x/a1形式を出力できます。(この際に試行錯誤したときの設定ファイルがconf_lua.pyです)

しかし、Sphinxで出力したtexファイルをLualatexに食わせてPDF出力したところ、思いっきり画像周りのレイアウトが崩れました。
しかもフォントの埋め込み方法が変わってしまいます。

この時点でゲームオーバーと判断。最終的にはdvipdfmxで出力したモノクロPDF(埋め込み画像は予めグレースケール化済み)をラック出版さんに確認出してOKもらった上で入稿しました。オンデマンド印刷なら場合によっては問題ありませんし、コピ本ならなおさら問題なく発行できます。

よく考えたらフォントも小塚を使ったのでAdobeの呪縛からも逃れられていませんでした。AdobeさんChrome OSでも使えるようにLinuxイラレを出してくれ。

結論

最も簡単なソリューションはインデザに課金することです。一人で本を作るならビルド環境よりコンテンツに力を注ぎましょう。
でも夏コミまでにかっこよくLuaLatexで出力したいから誰かナイスなPRをください

Custom Vision Service(Object Detection)で東方キャラを検出してみた

Custom Visionとは?

Custom Vision Service とは https://docs.microsoft.com/ja-jp/azure/cognitive-services/custom-vision-service/home

Custom Vision Service は、カスタム画像分類子の構築を支援する Microsoft Cognitive Services です。 画像の分類子を簡単に素早く構築、デプロイし、その性能を向上させることができます。 Custom Vision Service には、画像をアップロードして分類子をトレーニングするための REST API や Web インターフェイスが用意されています。

ざっくりいうと、画像認識のためのロジックは全部Microsoftがやってくれるので、ユーザーは学習データの投入・タグ付けをやるだけでAPI経由で推論結果を得られるというサービスです。 サービスイン当初は分類器のみでしたが、5/7のアップデートで物体検出(特定部分の座標取得)ができるようになりました。仕事で軽くプロトタイプを作ってみたところ、そこそこ使えそうな精度になったのでプライベート向けの題材で学習して公開してみることにしました。

東方Projectのキャラ判定機を作ろう

物体検出の題材として、学習データの分量を用意できることが必須かつ「分類することの意味」にもこだわってみたかったので東方キャラを題材に選んでみました。学習材料は自宅にある薄い本+公式書籍+αとなっております。 作業を簡略化するため、下記の条件に当てはまる箇所を撮影して学習データを用意します。

  • 表紙
  • 裏表紙
  • フルカラー

コードサンプルはこちら

GitHub : gensobunya/try-customvision

1.学習データを準備

  1. ログイン後、規約に同意してプロジェクト作成を選択すると、下のような画像アップロード画面に移動します。 f:id:gensobunya:20180630130525j:plain

  2. 学習データ(薄い本)を用意して1枚づつ裏表紙を撮影していきます。 f:id:gensobunya:20180630131414j:plain

  3. アップロードします f:id:gensobunya:20180630141126j:plain

  4. ドラッグ&ドロップでエリア選択をして、タグ付けします f:id:gensobunya:20180630141218j:plain

基本的にすべてマニュアル作業となりますが、編集画面で物体検出を自動でやってくれたりして結構スマートです。点線は自動検出部分。

f:id:gensobunya:20180630141424j:plain

2.学習させる

右上の「Train」ボタンを押すだけ…なのですが1タグあたり15枚以上の画像が必須となっているので、微妙に足りないキャラの学習データをフルカラーイラスト集から追加したり、全く足りないキャラのタグを削除したりします。
そもそも50タグが無料版の限界なので、東方キャラ全員の検知は不可能ですね…

f:id:gensobunya:20180630144324j:plain

初期状態で残ったキャラは以下の通りです。

学習が完了すると、試験結果も表示してくれます。特に何もしていないのでバランスが悪いですね… 学習結果と学習内容はIteration単位で保存されていますので、バージョン管理に活用できます。

検出の確信度閾値は左上のスライダーで調整できます。 よほど閾値を下げない限り、Precision>>>Recallとなっているので、そもそもキャラを拾い出すことに苦労している感じですね。

f:id:gensobunya:20180716144039j:plain

APIで結果を得ることもできるのですが、まずは画面上のQuick Testを使って、学習に使っていない電撃Playstationの表紙を判定させてみます。

f:id:gensobunya:20200312182427p:plain

霊夢は正しく検出できました!魔理沙は…がんばりましょう。

3.学習内容を改善する

まだまだ使いものにならないのでもうちょっとマシな学習内容を目指します。 閾値を0にして確認すると、霊夢は12%の確率で魔理沙ですが魔理沙は1.9%の確率でしか魔理沙と思われていません。

f:id:gensobunya:20200312182439p:plain

学習ロジックはいじれないので、学習データを下記の通りいじって再度Trainingします。だいたい5分から10分くらいでモデルが出来上がるので爆速と言って過言ではないでしょう。

  • データの少ないキャラタグを削除
  • 衣装変更(コート着・社会派ルポライターあや・他)を削除
  • 学習データ追加

結果、下記のキャラ(数字はデータ枚数)が残りました。主人公sと書籍キャラが多く残りましたね…

f:id:gensobunya:20200312182454p:plain

もう1度、電撃Playstationの表紙でテストします。

f:id:gensobunya:20200312182505p:plain 未だ魔理沙発見できず。0%時の候補からも消えてしまいました。 他の画像でも霊夢が射命丸になったりするので、あまりいいモデルとは言えません。

仕事で作ったときのように、全タグの学習枚数を揃えると精度があがるかもしれませんが、時間がかかるので今回はここまでにします。

4.WEBアプリ化する

APIを用いてWEBアプリ化します。PredictionURLをクリックして、必要な情報を確認できます。

f:id:gensobunya:20200312182520p:plain

Vue.jsとAxiosを使って適当に実装します。公開するときはAPIキーを隠すべきですがとりあえずローカルで動かしたいだけなので気にせず直接書きます。 APIJSON形式で検出したエリアを返してくるので、確信度でフィルタした後に画像にオーバーレイしてループしてボックス表示します。

  <template>
    <div id="app">

        <h2>SanteMedical Detector(Proto)</h2>
        <div>
            <input type="file" name="file" @change="onFileChange" class="waves-effect waves-light btn">
        </div>
        <div class="buttonwrapper" v-if="image">
          <button @click="removeImage" class="waves-effect waves-light btn">Remove image</button>
          <button @click="submitImage" class="waves-effect waves-light btn">Submit image
            <i class="material-icons right">send</i>
          </button>
        </div>
      <div v-if="image">
        <div class="imagewrapper">
          <img :src="image" />
          <div v-if="predictionData" v-for="prediction in predictionData" :key="prediction.tagID" class="detectionBox" :style="{width:prediction.boundingBox.width*100+'%',height:prediction.boundingBox.height*100+'%',left:prediction.boundingBox.left*100+'%',top:prediction.boundingBox.top*100+'%'}"></div>
        </div>
      </div>
      <div v-if="predictionData" class="tagwrapper">
        <ul class="collection">
          <li class="collection-header">
            <h4>Detected Tags</h4>
          </li>
          <li v-for="prediction in predictionData" :key="prediction.tagID" class="collection-item">
            <b>{{prediction.tagName}}</b>:{{prediction.probability}}
          </li>
        </ul>
      </div>
    </div>
</template>

<script>
import loadImage from 'blueimp-load-image'
import axios from 'axios'

const projectId = "YOUR PROJECT ID";
const predictionKey = "YOUR PREDICTION KEY";
const postURL = "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.0/Prediction/"+projectId+"/image";
const probabilityLine = 0.15; //確信度閾値


export default {
  name: 'app',
  data: function() {
    return {
      image: '',
      imgName: '',
      imgHeight: '',
      imgWidth:'',
      uploadFile: '',
      predictionData:''
    }
  },
  methods: {
      onFileChange: function(e){
        let files = e.target.files || e.dataTransfer.files;
        if (!files.length) {
            return;
        }
        if (!files[0].type.match('image.*')) {
            return;
        }

        this.createImage(files[0]);
        this.uploadFile = files[0];
        this.predictionData = '';
        this.occupancyRate ='';
        console.log(postURL);
      },
      createImage: function(file) {
        let reader = new FileReader();

        reader.onload = (e) => {
          //ローテーション
          loadImage.parseMetaData(file, (data) => {
            const options = {
              canvas: true
            };
            if (data.exif) {
              options.orientation = data.exif.get('Orientation');
            }
            loadImage(e.target.result, (canvas) => {
              const dataUri = canvas.toDataURL('image/jpeg');
              this.image = dataUri;
              this.imgHeight = canvas.height;
              this.imgWidth = canvas.width;
            }, options);
          });
          //ローテーションここまで
        }
        reader.readAsDataURL(file);
        this.imgName = file.name;
      },
      removeImage: function() {
        this.image = '';
        this.imgName = '';
        this.predictionData = '';
        this.occupancyRate ='';
      },
      submitImage:function() {
        let formData = new FormData();
        formData.append('shelfImage', this.uploadFile);
        let config = {
            method : 'post',
            headers: {
                'content-type': "multipart/form-data",
                'Prediction-Key': predictionKey
                }
        };

        axios.post(postURL, formData, config)
            .then(response => {
                //response 処理
                //確信度フィルタリング
                this.predictionData = response.data.predictions.filter( (items) => Number(items.probability) > probabilityLine);
              })
            .catch(error => {
                  // error 処理
                  this.predictionData = error;
            })
      }
  }
};
</script>

<style>
#app {
    text-align: center;
  }
img {
    width: 100%;
    margin: auto;
    display: block;
    margin-bottom: 10px;
  }
button{
  margin: 10px;
}
.imagewrapper{
  display: inline-block;
  width: 30%;
  position: relative;
  margin: 0px;
  padding: 0px;
  align-self: center;
}
.buttonwrapper{
  padding: 1em;
}
.detectionBox{
  position: absolute;
  border-width: 2px;
  border-style: solid;
  border-color: red;
}
</style>

Materializeを使って適当に装飾して、このように表示されます。 f:id:gensobunya:20200312182530p:plain

QiiTopSetというChrome拡張を作った

f:id:gensobunya:20191108220420j:plain
code

タグフィードを最初に見たいんだ!

Qiitaのデザイン変更に伴って、qiita.comへアクセスした際にqiita.com/trendへリダイレクトされる仕様が追加されて幾星霜。 最初は面白かったのですが、やはり興味のある分野に関する記事を探す頻度のほうが高いわけでしてアクセス即タグフィードクリック生活をしていましした。

時間の無駄なのでブックマークをタグフィードのURLに変更しようかと思いましたが、それでは面白くないのでChrome拡張で実装してみました。 普段仕事でコードを書いているわけではないので、記法の古さや使い回しにくさ、そもそもの可読性に難がある可能性大です。

オプション画面からリダイレクト先をタグフィードとタイムラインで切り替え可能です。

インストールはこちらから! Chrome web store - QiiTopSet ※仕様変更により動作しなくなったので、公開を終了しました

Code

Github - QiiTopSet

本体

Chrome ExtensionのAPIが全て非同期処理でコールバックを呼ぶ前提になっているので、中々苦戦しました。 chrome.storage.local.get のコールバック関数内にすべての処理を書くことで、設定取得→リダイレクト処理の順番で処理させることに成功しています。

//Qiitaのトップをタグフィードにリダイレクトする
'use strict';

const qiitaBaseUrl = "https://qiita.com/";

chrome.storage.local.get({"redirectPage": "tag-feed" }, (items)=>{

    let redirectFullUrl = qiitaBaseUrl + items.redirectPage;

    chrome.webRequest.onBeforeRequest.addListener( (detail) =>{
        return {redirectUrl : redirectFullUrl};
    },
        {urls: ["*://qiita.com/trend"]}, //リクエスト先がtrendになった場合発火
        ["blocking"]
    )
    console.log("redirect to "+ qiitaBaseUrl + items.redirectPage);

    //設定変更を監視
    chrome.storage.onChanged.addListener((newItems)=>{
        redirectFullUrl = qiitaBaseUrl + newItems.redirectPage.newValue;
        console.log("change redirect url to" + redirectFullUrl);
        })
    }
)

ただし、この方法だとChrome起動時に設定が変更された場合、リダイレクト処理に反映されないのでchrome.storage.onChangedを使って変更検知のロジックを入れています。 オプション画面で設定しないと設定用のオブジェクトそのものがundefinedになってしまいますが、chrome.storage.local.get({"redirectPage": "tag-feed" }...と記述することでnullの場合の初期値を設定できます。

chrome.storage.localではなくchrome.storage.syncを使っていれば同一Googleアカウントで設定を同期できますが、テストが面倒なので実施していません。

オプション用のページ

あまり面白みはありません。公式サンプルをちょいちょいと改造しただけです。

追記

下記のコードに加えて、「全ての投稿」も選択できるようにアップデートしました。

<!DOCTYPE html>
<html>
<head><title></title></head>
<body>

リダイレクト先:
<select id="redirectTo">
 <option value="tag-feed">タグフィード</option>
 <option value="timeline">タイムライン</option>
</select>

<div id="status"></div>
<button id="save">Save</button>

<script src="options.js"></script>
</body>
</html>

Chrome.storageAPIを使ってローカルストレージにオプションをオブジェクトとして保存しています。

'use strict';

const storage = chrome.storage.local;

// options.htmlからリダイレクト先を取得してobjectに格納
function save_options() {
    const page = {
        redirectPage : document.getElementById('redirectTo').value
    };

// オブジェクトをchrome.storage.localに保存
    storage.set(page,function() {
            let status = document.getElementById('status');
            status.textContent = 'Saved!'; //ボタン押したらフィードバック
            console.log('option saved as ' + page.redirectPage);

            setTimeout(function() {
                status.textContent = '';
                }, 750); //フィードバックを消す
          }
    );
}

// 初期表示は保存されている内容を表示する
function restore_options() {
    storage.get({
        redirectPage: "timeline" //getデータが無い時のための初期値
    },function(items) {
        document.getElementById('redirectTo').value = items.redirectPage;
    });
}
document.addEventListener('DOMContentLoaded', restore_options);
document.getElementById('save').addEventListener('click',save_options);

WP+GCEからHugo+Netlifyへの移行

f:id:gensobunya:20191108215555p:plain
hugo logo

静的サイトジェネレーターへの移行

性能・HTTPS対応などなどについて無料IaaSとWordPressを越えるものを探していたところ、静的サイトジェネレーターという存在を発見。

編集こそ、Markdownの作成で開発者向けではあるものの、HTML直打ちに比べるとテーマの適用やレイアウトのテンプレート化・ページングやタグページの生成などなど、圧倒的なメリットを得られる…

最終的に公開されるものは静的ファイルだけなので当然性能も良いしサーバの性能もそこまで必要ない。有名なツールはいくつかあったが、生成スピードとGolangに興味があったこともあってHugoを選択。特徴は以下の通り。

  • 超速ビルド(1記事1msec程度)
  • ローカルサーバ機能(hugo serve)コマンド打てばローカルですぐに検証できる
  • ドキュメントが割としっかりしている
  • テーマそこそこいっぱい

ホスティングはNetlify

静的ファイル配信にIaaS使うのはあまりにもアホらしいのでホスティングを探す。\ 定番はS3やCloudStrageの静的WEBサイト配信機能を使うことだが、CDNが有料だったりリダイレクトを維持できなかったりと少し課題あり。

そんな中Netlifyというすごいサービスがあることを知る。

HTTPSの静的コンテンツをホストするならs3よりNetlifyが俺の求めていたものだった https://qiita.com/shogomuranushi/items/6ab5bc29923b3f82c9ed

  • 1クリックでSSL(Let's encryptの証明書を自動取得・更新)
  • HTTP/2対応
  • CDN標準装備
  • Gitから更新検知して自動ビルド(!)
  • リダイレクト設定可能(独自記法)
  • 独自ドメイン利用可能(SSLもOK)
  • ここまで全部無料

要件全部満たしていたので即決! 以下は備忘録なので細かい手順は省いて設定とやったことのみダラダラと記載していく。

WordPressのデータをHugo用にエクスポートする

WordPress to Hugo ExporterというWordPressプラグインを利用。WPのプラグインディレクトリに置いて、管理画面にログインして有効化する。あとは1クリックで画像と記事の.mdファイルをZIPでダウンロードできる。以上!

Markdownの仕様上、広告などのインデントが深いとコード記述扱いされてしまうのでVSCodeの置換を使ってシコシコ修正する。

タグやカテゴリ、パーマリンクなどはHugo用のヘッダー部分が作成されており、そこに格納されている。サンプルは以下の通り。

---
title: 格安サイコンbryton Rider310を使ってみた
author: gen
type: post
date: 2017-09-27T11:26:26+00:00
thumbnail: DSC_7899.jpg
categories:
  - 未分類
tags:
  - インプレ
  - ガジェット

サムネイルは能動的に設定しないと作ってくれなかったので、自分で全記事に対して作成した…\ urlは所謂パーマリンク設定がそのまま反映される。hugoでは基本的に記事が/post/記事名/になってしまうが、この項目に設定をしておけば過去のURLを維持できる。

Hugoインストール、動作

Chocolatey経由でGoとHugoをインストールする。 作業したいディレクトリ上でhugo new siteを打てばよろしくディレクトリ構造を作ってくれる。

WPからダウンロードしたMarkdownファイルを/post/へ、画像ファイルは/static/へそれぞれ移動。 作業ディレクトリにconfig.tomlにWEBサイトの情報を投入して準備完了。(エクスポート時に作られたyamlファイルでも可)

自分の場合はこんな感じ。

#website setting
title = "幻想サイクル"
baseURL = "http://blog.gensobunya.net/"
languageCode = "ja-jp"
canonifyURLs = false
relativeURLs = true
theme = "mainroad"
googleAnalytics = "UA-xxxxxxx-x"

#system
contentDir = "content"
layoutDir = "layouts"
publishDir = "public"
buildDrafts = false
hasCJKLanguage = true
defaultLayout = "post"

検証はhugo serveコマンドを打てばlocalhost:1313上にサイトが展開される。 リンクも実際のURLではなくlocalhostによろしく変換されてくれるので便利。

hugoコマンドで/static/に実際のHTMLが生成されるが、Netlifyの場合はこの作業をGitにアップロードした後勝手にやってくれるので、ローカルで実施する必要はない。gitignoreにぶち込んでおく。

テーマは/themes/ディレクトリに移動して公式からテーマファイルをCloneしてくるスタイル。Cloneしたらテーマのフォルダ名をconfig.tomlに記載すればビルドの際に記載したテーマが適用される。\ 普段と違うテーマを試したい時はビルド時に引数で渡してやれば引数のテーマでビルドされる。

Netlify用設定

リポジトリのルートディレクトリにnetlify.tomlを作成してHugoのバージョンを記載する(しないと古いバージョンをビルドに使うためエラーになる)

自分の場合v0.27.1を使っていたので下記の通り記載。

[context.production.environment]
  HUGO_VERSION = "0.27.1"

ここまで書いたら、Github, Gitlab, bitbucketのどれかにリポジトリを作ってプッシュしておく。

Netlifyの会員登録をして、作ったリポジトリと連携すれば自動的にサイトのビルドが実施されてデプロイまで行われる。先程ローカルにあったサイトが生成されていることを確認して終わりだ。

リダイレクト

公式ドキュメントによるとWEBサイトのルートに_redirectを作ってそこに記述する。

Hugoの場合、サイトビルド時に変換してほしくないファイルは/static/以下に置くことになっているので、画像ファイルなどと一緒にここに配置すればOK。

Bloggerの頃のパラメータと、パーマリンクを維持するために以下の設定を記入。

# START paramater 301 redirect
/*param1=:value1   /:splat  301!
# END paramater 301 redirect

# START page 301 redirect
/:year/:month/:date/:slug /:year/:month/:slug 301!
# END page 301 redirect

:yearなどのいかにもYYYYを検知してくれそうなプレースホルダーが用意されているが、スラッシュの間ならなんでも認識してしまう罠があった。問い合わせてみたところ仕様らしい。

コンテンツと設定の移行作業はこれで全て完了。 あとは独自ドメインの設定をしてDNSを切り替えればNetlifyのサーバーからサイトが配信される。

Cloudflareを辞める場合、一旦ネームサーバーをGoogleに返す必要があるのでNSレコードを変更して1日待つ必要がある。\ 24h後にあらためてGoogle DomainsでNetlifyのサーバーにCNAMEを向けて移行の全作業が終了。

Cloudflare上で予めNetlifyにアクセスを向けておけば移行時間を減らせるかもしれないが、Cloudflare経由でNetlifyから配信することは推奨されていないようなのでやめておいた。

BloggerやめてWordpressに移行しようとした② - CDN,HTTPS編

f:id:gensobunya:20191108215739p:plain
cloudshell

※この記事は前回の続きです

システム環境

1クリックデプロイでGCEにWordpessをインストールした時のインストール先は下記の通り。OSはDebian

Wordpressは/var/www/html

f:id:gensobunya:20191108215801p:plain
ディレクトリ1

Apache2.4は/etc/apache2

f:id:gensobunya:20191108215819p:plain
Apachedir

余談だが、WEBターミナルからログインする際に自動作成されたユーザーは当然のようにsudo権限が割り振られている、便利すぎる。

CDN有効化

最初はLet's Encryptの証明書を使おうと思っていたのだが、どうやらCloudflareを使う場合はSSLもCloudflareで用意してくれるそうなので一本化することに。

まずはCloudflareに登録してアカウント作成する。 ウィザード画面に従って進んでいくと、どうやらドメインのネームサーバーをCloudflareのものに切り替える必要があるらしい。\ せっかくお名前からGoogle Domainsに切り替えて信頼性が上がったと喜んでいたところなのだが…ここで代替サービスを探すのも面倒なので言われるがままに設定。

レジストラに登録されていた内容を自動的に反映してくれるあたりは非常に便利。\ DNSが切り替わった時点でCDN経由になっているため、CDN利用はこれで完了。非常にお手軽だ。

HTTPS有効化

CloudflareのHTTPS利用は「Flexible」「FULL」「FULL(Strict)」の三種類がある。\ Flexibleはクライアント-CDNサーバのみ、FULLはクライアント-CDN-オリジン全てHTTPSで通信するがオリジンの証明書の正当性は検査されない(オレオレ証明書でも良い)。FULL(strict)はオリジンも正式な証明書である必要がある。

とりあえずFlexibleで設定し、後からFULL(Strict)に切り替える方針で作業をする。\ 管理画面でHTTPS設定を切り替えるとまずはフロントのWEBサイトがHTTPSで通信可能になる。\ HTTPSサイトはリダイレクトさせたいので.htaccessの出番。以下の記述を追加。

RewriteEngine on
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://example.com/$1 [R=301,L]

巷ではWordPress管理画面がリダイレクトループを起こす不具合が定番になっているらしいが、起きなかったので無視。

次は、CDNからオリジンサーバへの通信をHTTPSにする。\ Cloudflareの管理画面から証明書のCSR秘密鍵を手に入れることができるので、それをコピーしてApacheの適当なフォルダ内に突っ込む。

/etc/apache2/sites-availableにあるSSL用のデフォルト設定ファイルを見ると下記のディレクトリがお作法っぽいので同じ場所に入れる。

SSLCertificateFile      /etc/ssl/certs/gensobunya-net.crt
SSLCertificateKeyFile   /etc/ssl/private/gensobunya-net-private.key

Apacheの設定に追加して再起動。

a2ensite default-ssl.conf
service apache restart

CloudflareのHTTPS設定をFULLに切り替えてアクセス。\ この時点でうまく動かない。サーバーにはつながっているがファイルにアクセスできていないようだ…ということでWordPress用の設定を443に入れていないことに気がつく。

WordPress用confに記載されていた下記の内容をコピペ。

 <Directory />
    Options FollowSymLinks
    AllowOverride None
  </Directory>
  <Directory /var/www/html/>
    Options Indexes FollowSymLinks MultiViews
    AllowOverride All
    Order allow,deny
    allow from all
  </Directory>
  ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/
  <Directory "/usr/lib/cgi-bin">
    AllowOverride None
    Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch
    Order allow,deny
    Allow from all
  </Directory>

動いた。Cloudflareの設定をFULL(Strict)に切り替えて再度アクセス。\ 証明書の警告が出る。疑問に思って見てみると、Cloudflareが提供してくれるSSL証明書はLet's encryptなどの証明書ではなく、Cloudflare発行のオレオレ証明書だった、そりゃ警告されるわ。

改めて証明書を取るのは面倒なのでHTTPS設定はFULLで完了とすることに。

とりあえずWP環境完成

以上をもって、ブログ記事を安定して投稿できる設定は終わり。\ ただ、1週間くらい経ったあたりで下記の問題が頭に引っかかってくる。

  • GoogleDomainsでドメイン管理できていないのが気持ち悪い
  • Bloggerと同等のSEOアドセンス・エディタ・画像圧縮などを出来るようにプラグインをインストールしたら管理画面が重い
  • 上記プラグインのセキュリティアップデートが面倒
  • プレビューが重い
  • 検証環境欲しい
  • 並行作業をしすぎるとメモリ不足でMySQLが落ちる(要サーバ再起動)

最後の問題が致命的で、ブログごときに監視自動化なんぞ入れたくないという決意のもとに別サービスの検討をしたところ、静的サイトジェネレーターのHugoを見つけた。

次回、Hugo+Netlify編。