推しアイデア
カードのレアリティが高くなるほど演出が豪華になる仕様
―
カードのレアリティが高くなるほど演出が豪華になる仕様
他人の顔を聖遺物にして、勝手に評価したかったから
AIに出させればいいようなステータスを「敢えて」画像処理のアルゴリズムから数字を引っ張ってきて出力
身近にあるモノをスマホのカメラで撮影すると、AIが「伝説の聖遺物」として鑑定してくれるアプリ。写真の明るさ・色・輪郭のくっきりさといった画像特徴量から、レア度・属性・ステータス(攻撃/耐久/魔力)を機械的に算出し、その数値に矛盾しないよう生成AIが名前とフレーバーテキストをこじつけて創作する。

React :画面遷移やカード演出などの、UIの自由度を優先 FastAPI(Python):画像処理(YIQ変換・DCT・k-means等のNumPy処理)をPython側で行うため Gemini API(Gemini 3.5 Flash Lite):名前・由来の生成のみに使用。標準の3.6 Flashより高速・低コストなため採用
レア度・属性・ステータスはすべて自前のアルゴリズムで確定させ、Geminiにはその数値と矛盾しない「名前」「由来」の創作のみを依頼(数値はAIに変更させない設計)。

カメラで撮影、またはファイルから画像を選択。撮影前には4種類のフィルタをオン・オフとスライダーで調整でき、映像にリアルタイムで反映される。
これらのフィルタは、鑑定アルゴリズムが見ている特徴量(明るさ比・彩度・エッジ密度・なめらかさ)そのものに作用するため、狙って高レア・高ステータスを出すことが可能。(ほぼチート)

↑ ロード画面。UIはジグザグスキャン(データ圧縮で使用するやつ)を意識
撮影した写真を基に、豪華な演出とともに以下が生成される。(詳しいアルゴリズムは技術概要に記載)
レア度が上がるほどカードの演出が豪華になっていく。
★1(カス) 写真を濃淡画像に変換 ★2(ブロンズ)通常表示 ★3(シルバー)銀色の光沢エフェクト ★4(ゴールド)金色の光沢エフェクト ★5(レジェンド)虹色に輝くホログラム箔押し風エフェクト

↑ ★1の例

↑ ★3の例

↑ ★5の例
またカードは裏返すことができ、裏面では鑑定の根拠となった色分布(I・Q平面上の色相図)を確認できる。


鑑定した聖遺物はブラウザ内(IndexedDB)に自動保存され、図鑑画面から一覧表示、削除が可能。
AIに生成させればいいようなステータスを敢えて画像処理のアルゴリズムから数字を引っ張ってきて出力させる。本当に頑張ったのでマジで見てほしいです。
1.リサイズ(長辺768px): 写真の解像度がバラバラでも同じ基準で鑑定できるように、 長辺をRESIZE_LONG_EDGE=768pxに揃える。
2.画像のデータ圧縮:JPEG圧縮と同じやり方でデータ圧縮を行う。 8×8ブロックごとにDCT変換し、低周波側10係数だけを残して残りを0にしてから逆DCTで復元する。このとき、捨てた成分の総量とAC成分の総量を先に測っておき、これを後で耐久の計算に使用する。
3.YIQ変換:復元後のRGBをYIQに変換する。YIQは色を 「明るさ1つ(Y)+色味2つ(I・Q)」に分解する表現で、(I, Q)を平面上の点として見ると
に対応する。この性質を属性・魔力の計算に使用する。

↑ IQ平面。YIQ空間では色相を2次元で表現できることが分かる

↑ アプリでは、原点距離、原点まわりの角度を裏面に視覚的に分かりやすく記載してある
4.領域分け:画像中央から「短辺×0.40」を半径とする円を対象領域、それ以外を周辺領域と定義する。
# 出典: backend/appraisal.py RESIZE_LONG_EDGE: int = 768 SUBJECT_RADIUS_RATIO: float = 0.40 DCT_BLOCK_SIZE: int = 8 DCT_KEEP_COEFFS: int = 10 RGB_TO_YIQ: npt.NDArray[np.float64] = np.array( [ [0.299, 0.587, 0.114], [0.595716, -0.274453, -0.321263], [0.211456, -0.522591, 0.311135], ] )
まわりと比べて、対象がどれだけ光って見えるかで決定する。対象の明るさを画像全体の 明るさで割った比を求め、閾値を超えるごとにレア度を1段階上げる。
# 出典: backend/appraisal.py RARITY_THRESHOLDS: tuple[float, ...] = (0.85, 0.95, 1.05, 1.20) overall_mean_luminance = max(float(luminance.mean()), MIN_MEAN_LUMINANCE) luminance_ratio = float(luminance[subject_mask].mean()) / overall_mean_luminance def _to_rarity(luminance_ratio: float) -> int: # 閾値をいくつ超えたかがそのまま段階になる。 return int(np.searchsorted(RARITY_THRESHOLDS, luminance_ratio, side="right")) + 1
比を使っているので、部屋が明るくても暗くても周囲より対象が光っているかだけを見られる点が推しポイント。luminance_ratioが0.85未満ならレア度1(★1)、0.85〜0.95で★2、…、1.20以上で★5、という5段階になる。
ヒトコト:聖遺物って聖なるものだから、輝いてる方がすごいだろ!と思い実装。いい感じにはなってると思う。
対象領域の画素をI・Q平面上でk-meansにより3クラスタに分け、彩度合計(=原点からの距離の合計) が最大のクラスタの色相(角度)で属性を決定。
# 出典: backend/appraisal.py ELEMENT_SECTORS: tuple[tuple[float, str], ...] = ( (90.0, "火"), (180.0, "水"), (270.0, "木"), (360.0, "雷"), ) def _to_element(hue_angle: float) -> str: for upper_bound, element in ELEMENT_SECTORS: if hue_angle < upper_bound: return element raise AssertionError("hue_angle must be in [0, 360)")
単純な平均色ではなく、クラスタリングを使用することで、多色の物体でも色が打ち消し合わず、一番目立つ色を安定して抽出できるのが推しポイント。
(例:彩度の高い赤と緑が半々の物体を単純平均すると、彩度が薄い中間色になってしまい、
本来の「一番目立つ色」が消えてしまう)。乱数シードは0固定なので、同じ写真なら毎回同じ属性を出すことが可能(検証が楽なので)。
また、対象領域がほぼ無彩色(全クラスタの彩度合計がELEMENT_KMEANS_MIN_WEIGHT未満)の場合は
クラスタ結果を信頼せず、対象領域全画素の単純平均色相にフォールバックする。
色相の360度を90度ずつ4等分し、火・水・木・雷に振り分けている。
ヒトコト:元々は、対象物のI・Qを単純平均していたが、精度が結構悪かった。しかし、k-meansを使うと結構思った通りの属性になった。クラスタリングは使ってみたかった技術なので、ドハマりして嬉しい。
明るさ(Y成分)にSobelフィルタをかけ、対象領域内のエッジ密度(勾配の平均)を測定する。
※Sobelフィルタは、その画素のまわりで明るさがどれくらい急に変化してるかを検出するための仕組みである。 輪郭はだいたい明るい部分から暗い部分に急に切り替わるところなので、この急激な変化を数値化すると、エッジ(輪郭)が検出できる、という考え方。
# 出典: backend/appraisal.py edge_magnitude = _sobel_gradient_magnitude(luminance) edge_mask = eroded_center_circle_mask(height, width, EDGE_MASK_EROSION_PX) edge_density = float(edge_magnitude[edge_mask].mean())
輪郭が鋭く、細かい模様が多いものほど攻撃力が高くなる仕様。エッジ密度を測る際は、
対象領域の円をそのまま使わずEDGE_MASK_EROSION_PX(6px)ぶん内側に収縮させたマスクを使用する。
これは、円の境界そのものが「対象と背景の輝度差」による偽のエッジとして検出されてしまうのを避けるために実装。
ヒトコト:攻撃力の表現方法がなかなか思いつかず、苦肉の策でClaudeに聞いた結果、これが返ってきた。理解はしたが、値が一か所に固まりすぎてるので、結構無理やり実装した感じはある。あんま納得してない。
8×8ブロックのDCTで高周波成分を間引き、捨てた成分がAC成分全体に占める割合を耐久の低さとして扱う。
# 出典: backend/appraisal.py block_mask = block_center_circle_mask(height, width, DCT_BLOCK_SIZE) subject_ac_energy = float(ac_energy[block_mask].sum()) if subject_ac_energy / max(int(block_mask.sum()), 1) < MIN_AC_ENERGY_PER_BLOCK: detail_loss_ratio = 0.0 else: detail_loss_ratio = float(discarded_energy[block_mask].sum()) / subject_ac_energy
捨てた成分の割合が小さいほど、表面がなめらか、よって頑丈とみなす。ここでは、ブロック単位のDCT係数を使用することによって、緩やかな明暗差は各ブロックのDC成分(=ブロック平均)に吸収されるため、AC成分(=ブロック内の変化)だけを見ることで表面そのもののざらつきだけを抽出できる。これによって、光の当たり方で耐久値にブレが生じることを防ぐ。
また、対象がほぼ平坦(1ブロックあたりのAC成分エネルギーがMIN_AC_ENERGY_PER_BLOCK未満)な
場合は、AC成分がセンサーノイズに支配されて比が暴れる(=一番ツルツルな写真が逆に一番ザラザラと
誤判定される)ため、その場合は損失率を0(最もなめらか)に固定している。
ヒトコト:最初は輝度の分散で評価していたが、光の当たり方によって値が変わりすぎたのと、攻撃力との逆相関が強すぎたのでやめた。あと腐っていたJPEG圧縮の機能を有効活用したかったってのもある。
I・Q平面上での原点からの距離(=彩度)をそのまま魔力の元にしている。属性判定でも使った YIQの(I, Q)平面をそのまま使用している。
# 出典: backend/appraisal.py mean_in_phase = float(in_phase[subject_mask].mean()) mean_quadrature = float(quadrature[subject_mask].mean()) saturation = float(np.hypot(mean_in_phase, mean_quadrature))
ヒトコト:テラリアのラストプリズム(バカ強い魔法武器)が虹色のビームを出してるのを見て思いついた。単純だが結構気に入っている。
0〜100に正規化して、 レア度による倍率(0.5 + レア度 × 0.12)を掛ける。
# 出典: backend/appraisal.py def _normalize_to_100(raw_value: float, max_value: float) -> float: return float(np.clip(raw_value / max_value, 0.0, 1.0)) * 100.0 def _apply_rarity(base_value: float, rarity: int) -> int: corrected = base_value * (RARITY_BASE_MULTIPLIER + rarity * RARITY_STEP_MULTIPLIER) return int(min(round(corrected), STAT_MAX_VALUE))
正規化の基準値はClaudeに検証して算出してもらった。ほかの閾値も同様。
ヒトコト:将来的には閾値を自分で設定できるようになりたい
Gemini-3.5-flash-liteにプロンプトを送って生成させる。ファンタジーっぽい感じでレア度・属性・能力値に見合う名称と解説を生成してもらう。geminiが本文とそれを分解したものを生成し、それからルビを振ってくれる。分解したものをfast apiに送って本文を修復してもらう。
ヒトコト:geminiが生成した本文と、geminiが本文を分解してそれを修復したものが何故か一致せずにエラー起きてたのでfast apiに修復してもらうことになった。