Practice of Programming

プログラム とか Linuxとかの話題

Goでテーブル情報を自動生成してgoquで使う

前回(というか、今朝)、goquというGoのSQLのクエリビルダーを紹介しましたが、カラム名をハードコーディングするので、カラムに変更があると、どこに書かれてるのか探すのが大変ですね。grep、目grepで対応して、見えないバグが仕込まれるかもしれません。

そして、人(僕)はよくtypoする。

なので、information_schemaから、カラム名やテーブル名を取得してコード生成してしまえば良いのではと考えました。

Before, After

実際どんなイメージになるかというと。

Before:

   ex = goqu.Or(
        goqu.And(
            goqu.C("first_name").Eq(firstName),
            goqu.C("age").Lt(20),
        ),
        goqu.And{
            goqu.C("first_name").Eq(firstName),
            goqu.C("age").Gt(30),
        },
    )
    query, args := goqu.Dialect("mysql").From("user").Where(ex)

カラム名agefirst_name、テーブル名のuserをハードコードしています。

After:

   import (
        td "your-project/table-definition" // 適当です。
    )

    d := td.SchemaName.Table
    ex = goqu.Or(
        goqu.And(
            goqu.C(d.FirstName()).Eq(firstName),
            goqu.C(d.Age()).Lt(20),
        ),
        goqu.And{
            goqu.C(d.FirstName()).Eq(firstName),
            goqu.C(d.Age()).Gt(30),
        },
    )
    query, args := goqu.Dialect("mysql").From(d.Tablename()).Where(ex)

という感じに、メソッド呼び出しで書けるようになります。 (tablenameというカラム名があったら、メソッド名とかぶりますが、そんなカラム名は付けないでしょう。つけたとしても、常識的にtable_name命名すれば、TableNameになるから問題ない)

自動生成でやっていること

"your-project/table-definition" に、var SchemaName を定義します。そこに渡しているtypeは、以下のようなものです。

type tableName1 struct {}

func (tableName1) Tablename () string { return "table_name" }

func (tableName1) ColumnName1 () string { return "column_name1" }

func (tableName1) ColumnName2 () string { return "column_name2" }

type schemaName struct {
     TableName1 tableNaem1
     TableName2 tableNaem2
}

var SchemaName = schemaName{
    tableName1{},
    tableName2{},
}

手で書くなら、気が狂いそうになりますが、まぁ、自動生成なら許せる。

生成するコード

生成するコード自体は特に難しいことはやっておらず、information_schemaCOLUMNSテーブルを検索して、typeとfuncを作りまくるという単純なコードになります。

package main

import (
    "fmt"
    "log"
    "os"
    "pmall-api/internal/config"
    "pmall-api/internal/infrastructure/persistence/db"
    "strings"

    "github.com/iancoleman/strcase"
)

func main() {
    if len(os.Args) != 4 {
        log.Fatal("pass dsn, package name and database names with comma")
    }

    dsn := os.Args[1]

    packageName := os.Args[2]

    databases := make([]string, 0)
    for _, name := range strings.Split(os.Args[3], ",") {
        databases = append(databases, "'"+name+"'")
    }

    database, err := db.NewConnection(dsn)
    if err != nil {
        log.Fatal(err)
    }

    result, err := database.Query(
        fmt.Sprintf(`
      SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS
      WHERE TABLE_SCHEMA in (%s)
      ORDER BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME
      `, strings.Join(databases, ",")),
    )
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("package %s\n\n", packageName)

    currentSchema := ""
    currentTable := ""
    tables := make([]string, 0)
    if !result.Next() {
        log.Fatal("no rows")
    }

    for {
        var tableSchema string
        var tableName string
        var columnName string

        err := result.Scan(&tableSchema, &tableName, &columnName)
        if err != nil {
            log.Fatal(err)
        }

        if currentSchema == "" {
            currentSchema = tableSchema
        }

        fullName := tableSchema + strcase.ToCamel(tableName)
        if currentTable != tableName {
            tables = append(tables, tableName)
            fmt.Printf("type %s struct {}\n\n", fullName)
            fmt.Printf("func (%s) Tablename() string { return \"%s\" } \n\n", fullName, tableName)
            currentTable = tableName
        }

        fmt.Printf("func (%s) %s() string { return \"%s\"}\n\n",
            fullName,
            strcase.ToCamel(columnName),
            columnName,
        )

        hasNext := result.Next()

        if !hasNext || currentSchema != tableSchema {
            fmt.Printf("type %sDB struct {\n", tableSchema)
            structs := make([]string, 0)
            for _, table := range tables {
                fullName := tableSchema + strcase.ToCamel(table)
                fmt.Printf("    %s %s\n", strcase.ToCamel(table), fullName)
                structs = append(structs, fullName+"{}")
            }
            fmt.Print("}\n\n")
            fmt.Printf("var %s = &%sDB{\n    %s,\n}",
                strcase.ToCamel(currentSchema),
                currentSchema,
                strings.Join(structs, ",\n    "))
            tables = make([]string, 0)
        }

        if !hasNext {
            break
        }
    }
}

終わり

これで、typoや、カラム変更も怖くないですね。

めでたし。

GoのSQL Query Builder goquの紹介

GoでORMを使うのではなくて、SQLだけを作りたいという要望があって調べたところ、goquというものがみつかりましたので、試してみました。

書いてから気づきましたが、日本語の紹介記事は他にも結構ありました。

基本の使い方

Dialectを指定

MySQLPostgreSQLなどをDialectで指定できます。MySQLの場合は、以下のようにします。

import (
    "github.com/doug-martin/goqu/v9"
    _ "github.com/doug-martin/goqu/v9/dialect/mysql"
)

上記のような importを行い、Dialectで指定します。

goqu.Dialect("mysql")

SELECTの使い方

Tableを指定する

続けて、Fromでテーブルを指定します。

goqu.Dialect("mysql").From("user")

SQLにする

最後に、ToSQL() で、SQLを受け取れます。

   sql, _, err := goqu.Dialect("mysql").From("user").ToSQL()
    fmt.Println(sql)

SQL:

SELECT * FROM `user`

ToSQL()SQLにするのではなく構造体にマッピングする

ScanStructsScanStruct がありますので、下記のように構造体にマッピングすることも出来ます。

type User struct{
  FirstName string `db:"first_name"`
  LastName  string `db:"last_name"`
  Age       int    `db:"-"` // a field that shouldn't be selected
}
   users := make([]Users, 0)
    sql, _, err := goqu.Dialect("mysql").From("user").ScanStructs(&users)

Selectで関数を使う

goqu.L を使うことで、任意の文字を渡すことができるので、関数を渡すことも出来ます。

   sql, _, err := goqu.Dialect("mysql").Select(goqu.L("AES_ENCRYPT(num, \"hoge\")").As("crypted")).From("user").ToSQL()
    fmt.Println(sql)

SQL:

SELECT AES_ENCRYPT(num, "hoge") AS `crypted` FROM `user`

Whereを指定する

Whereで条件を指定することも普通にできます。

   sql, _, err := goqu.Dialect("mysql").From("user").Where(
        goqu.Or(
            goqu.C("age").Gt(10),
            goqu.C("age").Lt(20),
        ),
    ).ToSQL()
    fmt.Println(sql)

SQL:

SELECT * FROM `user` WHERE ((`age` > 10) OR (`age` < 20))

このWhereの書き方は、下記のようにも書けます。

       goqu.Or(
            goqu.Ex{
                "age":  goqu.Op{"gt": 10},
            },
            goqu.Ex{
                "age": goqu.Op{"lt": 20},
            },
        ),

この書き方なら、カラムがかぶらなければ複数条件をまとめて書けます。

       goqu.Or(
            goqu.Ex{
                "age":  goqu.Op{"gt": 10},
                "name":  goqu.Op{"eq": "name"},
            },
            goqu.Ex{
                "age": goqu.Op{"lt": 20},
            },
        ),

まぁ、下記のようにも書けますし、このスタイルのほうが書きやすいし、読みやすいかなと思います。

       goqu.Or(
            goqu.And(
                goqu.C("age").Gt(10),
                goqu.C("name").Eq("name"),
            ),
            goqu.C("age").Lt(20),
        ),

Placeholderを使う

今までのものは、すべて、SQLの中に変数でわたし値が埋め込み済みでしたが、Prepared(true)を使うことで、Placeholderを使うことも出来ます。

   sql, args, err := goqu.Dialect("mysql").From("user").Where(
        goqu.Or(
            goqu.C("age").Gt(10),
            goqu.C("age").Lt(20),
        ),
    ).Prepared(true).ToSQL()
    fmt.Println(sql)
    // SELECT * FROM `user` WHERE ((`age` > ?) OR (`age` < ?))
    fmt.Printf("%#v\n", args)
    // []interface {}{10, 20}

もっと複雑なSQL

かなり複雑なSQLも表現可能です(複雑っぽくしたかっただけなので、出来上がるSQLには特に意味がないです)。

   ds := goqu.From("user_item").Select(goqu.COUNT("*")).GroupBy("user_id").As("ui")

    sql, _, _ = goqu.Dialect("mysql").
        Select(goqu.COUNT("*")).
        From("user").
        Join(
            goqu.T("user_hobby"),
            goqu.On(goqu.I("user.id").Eq(goqu.I("user_hobby.user_id"))),
        ).
        Join(
            ds,
            goqu.On(goqu.I("user.id").Eq(goqu.I("ui.user_id"))),
        ).
        Where(
            goqu.Or(
                goqu.And(
                    goqu.C("age").Gt(10),
                    goqu.C("name").Eq("name"),
                ),
                goqu.C("age").Lt(20),
            ),
        ).GroupBy("user.id").Having(goqu.SUM("income").Gt(1000)).ToSQL()

SQL:

SELECT COUNT(*) FROM `user`
   NNER JOIN `user_hobby`
        ON (`user`.`id` = `user_hobby`.`user_id`)
  INNER JOIN (SELECT COUNT(*) FROM "user_item" GROUP BY "user_id") AS `ui`
        ON (`user`.`id` = `ui`.`user_id`)
 WHERE (((`age` > 10) AND (`name` = 'name')) OR (`age` < 20))
 GROUP BY `user`.`id`  HAVING (SUM(`income`) > 1000)

その他

詳しくは、ドキュメントを見てもらえれば良いのですが、希望するものはだいたいできるんじゃないのかなぁという印象を持ちました。SELECT FOR UPDATE なんかも使えるようです。

github.com

Updateの使い方

WhereはSELECTのときと同じなので省略します。Updateでテーブル名を渡して、Setでデータをセットします。

   sql, _, _ = goqu.Dialect("mysql").Update("user").Set(goqu.Record{"name": "name1"}).ToSQL()
    fmt.Println(sql)

SQL:

UPDATE `user` SET `name`='name1'

DELETの使い方

こちらも、WhereはSELECTのときと同じなので省略します。Deleteにテーブル名を渡します。

   sql, _, _ = goqu.Dialect("mysql").Delete("user").ToSQL()
    fmt.Println(sql)

SQL:

DELETE `user` FROM `user`

DELETEの後ろにテーブル名入れて使った覚えがあまりないんですが、正しいSQLでした。

INSERTの使い方

INSERTは、goqu.Recordでレコードのセットを作って、Rowsに渡します。

   users := []goqu.Record{
        {"first_name": "Bob", "last_name": "Yukon", "created": time.Now()},
        {"first_name": "Sally", "last_name": "Yukon", "created": time.Now()},
        {"first_name": "Jimmy", "last_name": "Yukon", "created": time.Now()},
    }
    sql, _, _ = goqu.Dialect("mysql").Insert("user").Rows(users).ToSQL()
    fmt.Println(sql)
 INSERT INTO `user` (`created`, `first_name`, `last_name`) 
VALUES ('2024-08-19 00:29:07', 'Bob', 'Yukon'),
               ('2024-08-19 00:29:07', 'Sally', 'Yukon'),
               ('2024-08-19 00:29:07', 'Jimmy', 'Yukon')

ColsVals でわけて指定したり、Rows に構造体を渡すこともできるようです。

終わり

ということで、一通りgoquの使い方を説明しました。

途中でも紹介しましたが、下記に、それぞれのオペレーションについてのドキュメントが詳しく載っています。

github.com

デメリットとしては、テーブル名や、カラム名をハードコーディングしないといけないところですが、そこに関しては、コードを自動生成することでなんとかできそうですね。

次回に続きます。

あまり頭を使わずに負けない三目並べを作る

4月書き忘れてることに気づいて、もう、5月31日ではないか....。

StreamDeckで遊ぶために、以前に三目並べを作ったのですが、その解説です。割と力技で作った感じなので、もっと頭のいい方法が知りたい方はぐぐると出てきます。

三目並べとは

説明するまでもないですが、9マスに先手後手で、◯×を埋めていって、3つ並べたら勝ちですね。 先手後手に関わらず最適な手を打てば必ず引き分けになります。

負けないためにはどうするか?

負けないためにはどうしたら良いのかというと、

  1. 負けそうなとき(相手の三目が成立する時)は妨害する
  2. 負ける配置(二目が同時に2つ成立する)にさせない
  3. 勝ち筋が多くなる(二目が同時に2つ成立する)ところに置く

の3点を守るのが基本です。

実装方法

マスをビットで表す

9マスをビットで表します。

# 三目並べのビット
1   2    4
8   16  32
64 128 256

見やすいようにテーブルにします。

a b c
A 1 2 4
B 8 16 32
C 64 128 246

勝利条件bit和で考える

勝利条件のbit和は下記のようになります(8列分)。

  • 84 ... 斜め(cA,bB,cC)
  • 7 ... A行
  • 56 ... B行
  • 448 ... C行
  • 273 ... 斜め(aC,bB,cA)
  • 73 ... a列
  • 146 ... b列
  • 292 ... c列

次で勝つときのbit和と、次どこを打てばよいかのbit は下記のようになります(8列x3=24)。

  • 84
    • 20: 64 ... cA(4)とbB(16)をとっていたら、 aC(64)が勝利手
    • 80: 4
    • 68: 16
  • 7
    • 3: 4
    • 6: 1
    • 5: 2,
  • 56
    • 24: 32
    • 48: 8
    • 40: 16
  • 448
    • 192: 256
    • 384: 64
    • 320: 128
  • 273
    • 17: 256
    • 272: 1
    • 257: 16
  • 73
    • 9: 64
    • 72: 1
    • 65: 8
  • 146
    • 18: 128
    • 134: 2
    • 130: 16
  • 292
    • 36: 256
    • 288: 4
    • 260: 32

負けないための戦略を考える

最初の3つの負けないためのルールを実行するためには、下記のようにする必要があります。

  1. 初手真ん中に置かれた場合は、端を取る(二目が2つ成立するのを防ぐ)
  2. ユーザーが対角、CPUが真ん中の場合は、辺の中を取る(二目が2つ成立するのを防ぐ)
  3. 真ん中が空いているなら、真ん中を取る(勝ちやすいだっけかな...。そうでもないかも)
  4. CPUの三目が成立するところに置く(勝利条件なので)
  5. 次にユーザーの三目が成立するところに置く(負けないために必要)
  6. 次にユーザーの二目が成立しそうなところに置く(ただし、ユーザーの二目が同時tに2つ成立する場合のみ)
  7. CPUが勝てそうなところ(次で勝つbit和となるところに置く、勝ち筋が同時に2つ発生する場所を優先)
  8. それでも決まらなかったらランダムで良い

だいたいこんな感じでやると負けません。 もし、人相手に勝ちたいなら、上を守っていれば、相手が油断してれば勝てるかと思います。

終わり

以上、あまり頭を使わずに負けない三目並べを作る方法でした。 ソースコードは下記にあります。

github.com

Google Photos APIを使うためのGoogle Developer Console での設定

こないだの続きです。 特に、Photos APIに限った話ではないですが。

OAuthアプリの設定を変更する

OAuth同意画面 から、「アプリを編集」します

OAuth同意画面の設定

「承認済みドメイン」が必須になります。

「保存して次へ」をクリック。

スコープの設定

スコープを追加または削除」で、スコープを設定します。

Google Photos APIの場合、どんなスコープがあるかは、下記に列挙されています。

developers.google.com

例えば、読み取りを行いたい場合は、https://www.googleapis.com/auth/photoslibrary.readonly が対象になります。

用意されている一覧には存在しないので、スコープの手動追加」で、スコープを追加します。

URLを入力して、「テーブルに追加」すると、一覧に追加されます。

「保存して次へ」行きます。

テストユーザーの設定

Photos APIは、「気密性の高いスコープ」です。そのため、Googleにアプリを認証してもらう必要がありますが、とりあえずテストするには、テストユーザーを登録しておけばよいです。

「ADD USERS」で、自分のGoogleアカウントのメールアドレスを登録しておきます。

アプリでスコープを指定する

先日のTauriのアプリでは、下記の場所にスコープに指定した値をエスケープして指定すればよいです。

auth.tsの11行目に、scopeがあるので、下記のように指定すればOKです。

    'scope=email%20profile%20openid%20' +
    'https:%2F%2Fwww.googleapis.com%2Fauth%2Fphotoslibrary.readonly%20' +

複数ある場合は、空白(%20)でつなげれば良いです。

ログインを試してみる

Google Developer Consoleの設定は、すぐには反映されないので、うまくいかなくても焦らずに、数分から数十分待ってみましょう。下記のようになればOKです。

「続行」を押せば、確認画面が出て処理が完了します。

APIの有効化

「有効なAPIとサービス」から、「APIとサービスの有効化」をクリックします。

photos で検索すると、Photos Library APIが見つかりますので、これを有効化しましょう。

これをしておかないと、実際にAPIを使うときにエラーになってしまいます。

おしまい

以上で、Google Phots APIを使えるようになりました。他のAPIも基本的には同じですね。 アプリを認証してもらう方法は下記に書いていますが、まだ、試していません。

support.google.com

Tauri で作ったアプリから Google にログインする

2月に記事を書けなかった...。まぁ日数少ないから、3/1なのでセーフということにしたい。

Tauri で Google等のOAuthでログインを行う方法の状況

さて、Tauri で 作ったアプリで Google のログインを行う方法があるのだろうかと、色々調べていたのですが、まだ、議論中の話題のようです。

  1. tauri の特定のrevisionを指定したらどうやらできるかも
  2. tauri_plugin_oauth を使えばできるっぽい

という感じでしたが、1番目はちょっとな...というのと、2番目はもうちょっと具体例がほしいな...と思って、最初にリンクしたスレッドを、不定期に追っていたのですが、3日前にtauri_plugin_oauth を使ったサンプルを作られた方の投稿がありました。

github.com

ありがたすぎる。

というわけで、この実装を参考に試してみましたが、Firebaseの設定等も必要になるので、そちらの情報をまとめながら、どこに何を記載したら良いかを書いていきます。

ただ、この方法、自分だけ使う分には良いですが、セキュリティ的に懸念があるんじゃないかと思われます。配布するような場合には、このまま使うのは避けたほうが良いでしょうね。

Firebaseの準備

Firebaseを使うので、Firewabaseにプロジェクトとアプリが必要です。

プロジェクトを作成します。

この後、Google Analyticsを有効にしますかとか聞かれます。好きにして下さい。

「アプリにFirebaseを追加して利用を開始しましょう」の下の、</> タグマークを選びます。

ウェブアプリにFirebaseを追加するページになります。 名前を適当に設定して、Firebase Hosting は使わなくて良いです。

「Firebase SDKの追加」で、npm install firebase の案内と、コードが表示されるので、コピーしておきましょう。コードは以下のような感じです。

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
const firebaseConfig = {
  apiKey: "*************************",
  authDomain: "*************************.firebaseapp.com",
  projectId: "*************************",
  storageBucket: "*************************.appspot.com",
  messagingSenderId: "*************************",
  appId: "*************************"
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);

この情報は、冒頭で紹介した、サンプルプログラムの、app.tsの5行目 に入れるものです。

次に、コンソールに戻って、「Authentication」を選びます。

その後、「始める」をクリックすると、下記のページに遷移します。

Sign-in method」を選んで、Google を選択します。

「有効にする」をクリックして、メールを選んで保存します。

Google Cloud Console での設定

次に、Google Cloud Console に移動します。

左上からプロジェクトが選べるので、未選択状態だったら、先程Firebaseで作ったプロジェクトを選びましょう。

APIとサービス」の「認証情報」を選びます。上記の手順でやると、OAuth2.0クライアントが2つできあがってるんですが、なんですかね...。 片方を選べば良いと思います。

右上の方に、クライアントIDがあるので、それをコピーします。 auth.tsの9行目<CLIEN_ID_FROM_FIREBASE>を置き換えるものです。

また、「承認済みのリダイレクトURI」に、http://localhost を追加しておきます。

APIキーに制限をつけましょう。何もしないと、下記のようになっています。こちらをクリックして、APIキーを制限します。

Webサイトを選んで、localhost を許可しておきます。

これで、ログインページが表示されるようになります。

なお、これらの設定が有効になるまでしばらくかかるようなので、数分から数十分待ってみましょう。

ただ、このままだと、下記のような感じで、怪しすぎるので、アプリ名を設定しましょう。

「OAuth同意画面」から「アプリを編集」します。

とりあえず、ここの「アプリ名」を変更すれば、同意画面に出てくる名前も変わります。 メールアドレスだけ設定する必要がありますが、それ以外は、特に設定しなくても先に進めます。

コードの編集と実行

既に書いたとおりですが、二箇所編集するだけです。

git clone https://github.com/igorjacauna/tauri-firebase-login.git
cd tauri-firebase-login

src/services/firebase/app.ts を編集します。

import { initializeApp } from 'firebase/app';

// Here goes the firebase project config
const firebaseConfig = {
  // Fill here
};

export const firebaseApp = initializeApp(firebaseConfig);

Fill here に、最初の方に取得した、firebaseConfig の内容をコピーします。

次に、src/services/firebase/auth.ts の <CLIENT_ID_FROM_FIREBASE> を置き換えます。

import { open } from '@tauri-apps/api/shell';
import { getAuth, GoogleAuthProvider, getRedirectResult, signInWithRedirect, signInWithCredential } from 'firebase/auth';

const openBrowserToConsent = (port: string) => {
  // Replace CLIEN_ID_FROM_FIREBASE
  // Must allow localhost as redirect_uri for CLIENT_ID on GCP: https://console.cloud.google.com/apis/credentials
  return open('https://accounts.google.com/o/oauth2/auth?' +
    'response_type=token&' +
    'client_id=<CLIEN_ID_FROM_FIREBASE>&' +
    `redirect_uri=http%3A//localhost:${port}&` +
    'scope=email%20profile%20openid&' +
    'prompt=consent'
  );
};

以上で、サンプルプログラムを動かしてみます。

cargo-tauri dev

超シンプルなログインの画面だけが表示されます。

クリックすると、下記のようにGoogleのログインが表示されます。

ログインすると、auth.tsの25行目 で、アクセストークンが取得できますので、これをどこかに保存しておけばよいでしょう。

おしまい

以上で、Google にログインして、アクセストークンを取得できるようになりました。 ですが、最初に書いたとおりセキュリティ上はあまりよろしくないかと思います。

APIキーの制限がlcalhostなので、誰でもそのキーを使って、APIを叩けます。 なので、本来は自分のWebサイト上で、OAuthの処理をすべきでしょう。

一旦テスト開発で...というのには十分役に立つかと思います。

RustでExifのMakerNoteからPanasonicのカメラのレンズ情報を雑に取得する

Pnaasonicのカメラを使ってるのですが、Exifからレンズ情報が取れない...のですよね。

問題

通常、ExifのLensModelにレンズ名が入ってくるのだと思うのですが、Panasonicのカメラでは、入ってなさそうです(フルサイズ系(S系)は入っているかも)。少なくとも、僕の使ったことのあるカメラ、GM5, GX8, G9等では入ってなかったです。

ですが、ExifのMakerNoteの中に、データはあるようなので、それを使えばいけそうです。

ExifをParseするのに、rexifを使う

ExifをRustでParseするのには、rexifというものがありますので、これを使いました。

docs.rs

rexifでparseすると、entriesというメソッドで、各エントリーが取得できるので、MakerNoteの場合に、別の関数でentryの、ifd.ext_dataをparseします。下記のような感じですね。

            struct Exif {
               lens_model: String,
               // 他にも適当に
            }

            let mut data = Exif{lens_model = "".to_string() };
            let exif_data = rexif::parse_file(file_path);
            for e in exif_data.unwrap().entries {
                match e.tag {
                    rexif::ExifTag::MakerNote => {
                        let d = get_lens_from_maker_note(e.ifd.ext_data);
                        if d != "" {
                            data.LensModel = d;
                        }
                    },
                    // 他にも適当に
                }
            }

get_lens_from_maker_note でやっていること

この関数では、rexifから取ってきたe.ifd_ext_data をparseします。この型はVec<u8> のバイト列です。Panasonicのカメラの場合、先頭の12バイトは、80, 97, 110, 97, 115, 111, 110, 105, 99, 0, 0, 0 となりますが、これはPanasonic\0\0\0になります。

先頭がPanasonic\0\0\0の場合に、レンズ情報がどこにあるかですが、固定長というわけではなさそうだったので、先頭から適当にそれっぽい文字がでるまで探すことにしました。

確認できたところでは、LUMIX, LEICA, OLYMPUS, SIGMA 等の文字の後に、レンズ情報が来ていました。LEICA は、LEICA認証の通ったPanasonicのレンズ(例: LEICA DG 100-400/F4.0-6.3)の場合になります。

なので、下記のような感じで取るようにしました。

  1. 最初にPansonic\0\0\0があるか判定
  2. 制御文字以外をstd::char::from_u32で変換して連結していき、最後の10文字だけ取っておく
  3. レンズのプレフィックス用の正規表現にあたるかチェック
  4. そこから制御文字の手前までをレンズ情報とする

という、とても雑な感じです(正規表現にマッチするレンズ情報がない場合、最後まで読んでしまうので、効率が悪い)。

コードは以下のようになります。

// currently only for Panasonic camera
fn get_lens_from_maker_note(data: Vec<u8>) -> String {
    if data.len() < 9 {
        return "".to_string();
    }

    // Panasonic
    let panasonic: [u8; 12] = [80, 97, 110, 97, 115, 111, 110, 105, 99, 0, 0, 0];
    let first12chars = &data[0..12];

    // return when first 12 char is not "Panasonic\0\0\0"
    if first12chars != &panasonic {
        return "".to_string();
    }

    // Lens name prefix regex(I only confirmed LUMIX, LEICA, OLYNMUS, SIGMA)
    let re = regex::Regex::new("(?i)(LUMIX|LEICA|OLYMPUS|SIGMA|TAMRON|KOWA|COSINA|VOIGT|VENUS)$")
        .unwrap();

    let mut i = 12;
    let mut str = "         ".to_string(); // dummy 9 chars
    while i < data.len() {
        let d = data[i];
        if d < 32 || 126 < d {
            i += 1;
            continue;
        }
        // enough length for regex
        str = str[str.len() - 9..str.len()].to_string();
        str.push(std::char::from_u32(data[i].into()).unwrap());

        let captures = re.captures(&str);
        if captures.is_some() {
            let cap = captures.unwrap();
            let mut lens = cap[0].to_string();
            let mut i2 = i;
            while i2 < data.len() {
                i2 += 1;
                if data[i2] < 32 || 126 < data[i2] {
                    return lens.to_string();
                }
                lens.push(std::char::from_u32(data[i2].into()).unwrap());
            }
        }
        i += 1;
    }
    return "".to_string();
}

雑すぎやしないか?

もっと頑張ってMakerNoteを解析するという手段もありなのですが、MakerNoteの仕様は、各社バラバラです。まともに対応すると、PerlのExifToolsのコードを見るとかする必要がありそうなのですが、そこまで時間を取れないので、一旦これで手を打ちました。

※ExifToolsは下記です。 metacpan.org

ですが、12バイト目がエントリー数で、そこから、12バイト単位でエントリーが並んでおり、そこにはレンズ情報はないということはわかったので、せめて下記のような追加を行いました。

下記の用に12から始めているところを

   let mut i = 12;

コメント通りですが、(data[12] のエントリ数 + 1 ("Panasonic\0\0\0"(12byte)) ) x 12byte分をスキップすることにしました。

    // // safely skip 12byte x (data[12](num of entries) + "Panasonic\0\0\0")
    let mut i: usize = usize::from(data[12] + 1) * 12;

まぁ、大した効果はないとは思います。

後は、正規表現でやるより、スライスでチェックしたほうが速いかも...とか思いましたが、正直速度はなんの問題もないレベルなので、一旦これで良しとします。

おわり

というわけでかなり雑にではありますが、レンズ情報を取得できるようになりました。

めでたし。

Pythonで目標をセンターに入れてCrop と 月を撮るカメラの設定 と ChatGPT

ちょっと前の話になりますが(2022/11/8)、皆既月食天王星食がありましたね。見られましたでしょうか?

僕は途中から、望遠レンズを持ち出して撮影していましたが、久しぶりに撮ると設定とかを忘れちゃっててだめですね。なかなか手こずりました。

で、何枚も撮ったわけですが、ご存知の通り月は動くので...常にセンターで撮れていたわけもなく...。写真を並べても月があっちこっちに行っていて、時系列に順番に見ようと思っても、ちょっと残念な感じです。

とはいえ、これをいい感じに手でトリミングするのも、めんどくさすぎる...ということで、OpenCVを使って、軽くやってみました。

なお、下記の記事が非常に参考になりました。

最初に結果

こんな感じのgifができあがります。animation pngも作りましたが、重いので。

目標をセンターに入れてCropする Python の script

大して長くも無いのでコメントに説明を書いています。 前提として、「写真内の月の大きさは同じ」です。

import cv2
import glob
import numpy as np
import os
import re
from PIL import Image

# 円の半径
min_radius = 520
# ~/Downloads/の下のdirectory名
path_prefix = 'eclipse'

# クロップするサイズ
crop_radius = min_radius + 150

# ファイルを取得(Lumixで撮ると、Pがファイルの最初に付く)
files: list = glob.glob("/home/ktat/Downloads/" + path_prefix + "/P*.JPG")
files = sorted(files, key = lambda item: int(re.sub('/.+/P(\d+)\.(?:jpg|JPG)$', r'\1', item, 1)) )

n = 0
for file in files:
    n += 1

    # tmp に cropしたファイルを保存します
    fname = '/tmp/{}-{}.png'.format(path_prefix,n)

    # 途中で設定変えて続きからやりたいときのために、ファイルがあったらskip 
    if os.path.isfile(fname) is True:
        continue

    img = cv2.imread(file)
    # ノイズ除去
    dst = cv2.GaussianBlur(img, (3,3), 0)
    # グレースケール化
    gray = cv2.cvtColor(dst, cv2.COLOR_BGR2GRAY)
    # 円の検出
    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.1, minDist=100, param1=100, param2=30, minRadius=min_radius, maxRadius=min_radius + 50)

    if circles is None:
        print("skip {}".format(file))
        # 検出できなかったものがどんなものだったかわかるように保存しておく
        cv2.imwrite("sample-gray-{}.png".format(n), gray)
        cv2.imwrite("sample-noize-cancel-{}.png".format(n), dst)
        continue
    else:
        print("OK {}".format(file))

    circles = np.uint16(np.around(circles))

    x = 0
    y = 0
    i = 0
    for circle in circles[0, :]:
        i = i +1
        x += circle[0]
        y += circle[1]

    # 円が複数検出される可能性があるので(別の場所ではなくて、同じところに複数)
    # 中心座標(平均)を出す
    x = x / i
    y = y / i

    im = Image.open(file)
    left = x - crop_radius
    right = x + crop_radius
    up = y - crop_radius
    bottom = y + crop_radius

    # crop したファイルを保存
    im.crop((left,up,right,bottom)).save(fname, format='png')

以下、ポイントだけ。

ノイズ除去

下記の部分です。除去しないと円の検出がうまく行かないケースが多かったので、先にノイズ除去を行っています。 コントラストがはっきりしていればよいのでしょうが、月食が終わってくると、明るい部分はどうしても輪郭がわかりにくくなってしまうところがありますね。それに対して、多少の寄与はありそうです。

dst = cv2.GaussianBlur(img, (3,3), 0)

円の検出

円の検出は、下記の部分で、minRadius, maxRadius が、円の最小、最大直径です。 最初に書いたように、写真内の月の大きさは同じです(同じ焦点距離で撮っていたので)。

    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.1, minDist=min_radius param1=100, param2=30, minRadius=min_radius, maxRadius=min_radius + 50)

dp は、0.8-1.2くらいが良いと

circlesに検出された円の情報が複数(同じ場所で複数)来る場合があるで、平均を取っています。

    for circle in circles[0, :]:
        i = i +1
        x += circle[0]
        y += circle[1]

    # 円が複数検出される可能性があるので(別の場所ではなくて、同じところに複数)
    # 中心座標(平均)を出す
    x = x / i
    y = y / i

crop して保存

中心座標(x,y)がわかれば、後は、crop して、保存しているだけです。

    im = Image.open(file)
    left = x - crop_radius
    right = x + crop_radius
    up = y - crop_radius
    bottom = y + crop_radius

    # tmp に cropしたファイルを保存します
    fname = '/tmp/{}-{}.png'.format(path_prefix,n)
    # crop したファイルを保存
    im.crop((left,up,right,bottom)).save(fname, format='png')

アニメーションgif/pngを作る Python の script

cropする処理が重いので、毎回やりたくはないので、別のプログラムとしました。 crop後に余計な画像は削除して連結をやり直しとかしたいからです。

単純に画像を取ってきて並べ替えて、連結してアニメーションにしているだけです。

import glob
import re
from PIL import Image

path_prefix = 'eclipse'
files: list = glob.glob('/tmp/' + path_prefix + "*.png") 
files = sorted(files, key = lambda item: int(re.sub('/tmp/' + path_prefix + '-(\d+)\.png', r'\1', item, 1)) )
cropped: list[Image] = []
cropped_mini: list[Image] = []

for item in files:
    im = Image.open(item)
    cropped.append(im)
    cropped_mini.append(im.resize((200,200)))

cropped[0].save("result-{}.png".format(path_prefix), format="png", save_all=True, append_images=cropped[1:])
cropped_mini[0].save("result-{}-mini.gif".format(path_prefix), format="gif", save_all=True, append_images=cropped_mini[1:])

おしまい

月の大きさが同じだとかなり良い感じにcropできます。しかも割と簡単。

月の大きさが違う場合は、複数の半径の候補を持っておいて、大きいのから試していって、len(circles) == 1 を見つけるまでやると良いかもしれません。

以下は、2018/01/31の月食ですが、これはサイズがバラバラでした。色々試してみましたが、もう少し調整が必要そうです。 (これでも、うまく行かなかった画像は、目視で削除)

おまけ: 月を撮るときのカメラの設定

僕が使ったのは、下記の機材です。ちなみに普段は鳥を撮っているので、月をしょっちゅう撮ってるわけではないです。

普通の月を撮る

普通の月はかなり明るいので、手持ちでも大丈夫です。 iso700-800くらいで、シャッタースピードも1/300-400とかでも十分撮れると思います。

下記は、

皆既月食を撮る

対して、皆既月食は暗いので、三脚を使ったほうが良いです。また、安物の三脚の場合、シャッターを切る指の動きでブレる可能性もあるので、シャッターディレイ(シャッターボタンを押してから数秒後にシャッターが切れる設定)も行うと良いかもしれません。 後、三脚使う場合は、手ブレ補正はオフにしましょう。

下記は、

おまけ2: ChatGPTにお願いしてみた

コード書いてから、ChatGPTにお願いしたらどうなるだろうか、試してみました。

お願い(英語はだいぶ雑)。

Write code with python. The spcification is the following. 
1. several photos is given as commad line argument in which there is any shape of moon. 
2. sort them with file mtime
3. detect a moon center position in image. 
4. crop it from center of the moon to its radius with 200px margin. 
5. resize them as their radius are same. 
6. create animation gif with the results.

restriction:
1. consider memory usage. don't use huge memroy.. 
2. avoid misdetection of a moon as possible 
3. Please check your code with mypy and if error occurs, fix it.
4. implent whole code. avoid todo code.
5. you can assume the largest object in image as a moon.
6. the size of animation gif shoud be less than 20MB

ありがたいお言葉を若干修正したもの。

import os
import sys
from typing import List, Tuple

import cv2
import imageio

# Get the file paths of the images from the command line arguments
image_paths = sys.argv[1:]

# Sort the image paths by file modification time
image_paths.sort(key=lambda x: os.path.getmtime(x))

# Create a list to store the cropped and resized images
images = []

# Iterate over the image paths
for image_path in image_paths:
    # Load the image
    image = cv2.imread(image_path)

    # Convert the image to grayscale
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Use Otsu's thresholding method to get a binary image
    _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # Find the contours in the binary image
    contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)

    # Find the contour with the largest area
    cnt = max(contours, key=cv2.contourArea)

    # Compute the center of the contour
    M = cv2.moments(cnt)

    # 修正
    if M['m00'] == 0:
        continue

    cx = int(M['m10'] / M['m00'])
    cy = int(M['m01'] / M['m00'])

    # Compute the radius of the contour
    radius = int(cv2.minEnclosingCircle(cnt)[1])

    # 修正
    if radius == 0:
        continue

    # Crop the image from the center of the moon to its radius with a 200px margin
    cropped = image[cy - radius - 100:cy + radius + 100, cx - radius - 100:cx + radius + 100]

    # Get the width and height of the cropped image in pixels
    h, w = cropped.shape[:2]

    # 修正
    if w == 0 or h == 0:
        continue

    # Resize the cropped image so that the radii of the moons in all images are the same
    resized = cv2.resize(cropped, (w, h))

    # Add the resized image to the list of images
    images.append(resized)

# Set the frame rate of the gif to 24 frames per second
fps = 24

# Create a writer object that will write the gif to a file
writer = imageio.get_writer('animation2.gif', fps=fps)

# Write the frames to the gif file
for image in images:
    writer.append_data(image)

# Close the writer object
writer.close()

サイズ制限は無視されてしまったので、手で修正(画像の大きさで指定したほうが良かった)。 後、明らかに変なフレームは手で削除しました。

割と良い線いきますね...。