Apps

Resize Texture|画像サイズを最適化

はじめに

パース作成をする際にはマテリアルを貼り付ける必要があるのですが、この時、Unreal Engineなどに持っていきパッケージング化する必要がある場合は動作が重くならないようにマテリアルのサイズに気を使います。

普段のパース制作でそこまで気にする必要はありませんが、今回はマテリアルに貼り付ける画像サイズを最適化するアプリを開発しました。

実装:手順とコードの解説

実装手順

  1. Reactプロジェクトの作成
  2. 必要なライブラリのインストール
  3. コンポーネントの作成
  4. 画像リサイズ処理の実装
  5. リサイズ&ダウンロード機能の実装
  6. ZIPで一括ダウンロード機能の実装
  7. 動作確認

1. Reactプロジェクトの作成(create-react-app の使用)

まずは create-react-app を使って、新しいReactプロジェクトを作成します。以下のコマンドを実行してください。

mkdir frontend
npx create-react-app .
cd frontend

npx を使用することで、グローバルに create-react-app をインストールせずにプロジェクトを作成できます。作成が完了したら、プロジェクトフォルダ(frontend)に移動します。

2. 必要なライブラリのインストール

画像の一括ダウンロードには jszipfile-saver を使用します。

npm install jszip file-saver

JSZip:ZIP ファイルを作成するライブラリ

file-saver:ファイルのダウンロードを簡単にするライブラリ

3. プロジェクトのディレクトリ構成

ログイン機能を実装しやすいように、以下のようなディレクトリ構成を設定します。

frontend/
├── src/
│   ├── components/
│   │   ├── pages/
│   │   │   ├── Home.js         # 画像アップロード・リサイズ画面
│   │   ├── templates/          # 共通レイアウト
│   │   │   ├── Appbar.js
│   │   │   ├── Footer.js
│   ├── utils/                  # ユーティリティ関数
│   │   ├── resizeImage.js       # 画像リサイズ処理
│   ├── App.js
│   ├── index.js
│── public/
│── package.json
│── README.md
mkdir src\components\pages src\components\templates src\utils
cd src\components\pages && echo. > Home.js
cd ..\templates && echo. > Appbar.js && echo. > Footer.js
cd ..\..\utils && echo. > resizeImage.js

説明

  1. mkdir コマンドでフォルダを作成
  2. cd コマンドでディレクトリを移動
  3. echo. > ファイル名 を使い、空のファイルを作成

このコマンドを実行すると、以下のディレクトリ&ファイルが作成されます。

4.画像リサイズ処理の実装

src/utils/resizeImage.js に、画像をリサイズする関数を記述します。

resizeImage.js

export const resizeImage = (file, targetWidth, targetHeight, cropPosition = "left-top") => {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            const originalWidth = img.width;
            const originalHeight = img.height;

            // クロップサイズを決定(短い辺を基準に)
            const minSide = Math.min(originalWidth, originalHeight);
            const availableSizes = [128, 256, 512, 1024, 2048];
            const bestSize = availableSizes.filter(size => size <= minSide).reduce((a, b) => Math.max(a, b), availableSizes[0]);

            const finalSize = Math.min(targetWidth, targetHeight, bestSize);

            // キャンバスの設定
            canvas.width = finalSize;
            canvas.height = finalSize;

            // クロップの開始位置を計算
            let startX = 0, startY = 0;
            switch (cropPosition) {
                case "center":
                    startX = (originalWidth - finalSize) / 2;
                    startY = (originalHeight - finalSize) / 2;
                    break;
                case "right-top":
                    startX = originalWidth - finalSize;
                    startY = 0;
                    break;
                case "center-top":
                    startX = (originalWidth - finalSize) / 2;
                    startY = 0;
                    break;
                case "left-bottom":
                    startX = 0;
                    startY = originalHeight - finalSize;
                    break;
                case "right-bottom":
                    startX = originalWidth - finalSize;
                    startY = originalHeight - finalSize;
                    break;
                default: // "left-top"
                    startX = 0;
                    startY = 0;
            }

            // 画像をクロップして描画
            ctx.drawImage(img, startX, startY, finalSize, finalSize, 0, 0, finalSize, finalSize);

            // 画像を Blob に変換
            canvas.toBlob((blob) => {
                if (blob) {
                    resolve({
                        url: URL.createObjectURL(blob),
                        width: finalSize,
                        height: finalSize,
                        originalName: file.name.replace(/\.[^/.]+$/, ""),
                        originalWidth,
                        originalHeight
                    });
                } else {
                    reject(new Error('画像の変換に失敗しました'));
                }
            }, 'image/png');
        };

        img.onerror = (err) => reject(err);

        const reader = new FileReader();
        reader.onload = (e) => (img.src = e.target.result);
        reader.readAsDataURL(file);
    });
};
resizeImage.jsの詳しい解説を見る

resizeImage.js は、画像をクロップしながらリサイズするための関数を提供するユーティリティファイルです。
このコードは React の Hooks を使用していませんが、非同期処理 (Promise) を使って画像の処理を行っています。

1. Promise を使った非同期処理

return new Promise((resolve, reject) => { ... });
  • 画像のリサイズ処理は非同期で行われるため、Promise を返す。
  • 成功すれば resolve({ ... }) でデータを返し、失敗すれば reject(new Error(...)) でエラーを投げる。

2. <img> タグを使って画像を読み込む

const img = new Image();
img.onload = () => { ... };
img.onerror = (err) => reject(err);
  • img 要素を JavaScript で作成し、画像を読み込む。
  • 画像が正常にロードされたら onload で処理を開始。
  • 画像のロードに失敗した場合 onerrorreject を呼び、エラー処理を行う。

3. 画像のサイズを取得

const originalWidth = img.width;
const originalHeight = img.height;
  • 読み込んだ画像の元の widthheight を取得。

4. 正方形クロップのためのサイズを決定

const minSide = Math.min(originalWidth, originalHeight);
  • 画像の短い辺を minSide として取得。
  • これを基準に、適切なクロップサイズを決定。
const availableSizes = [128, 256, 512, 1024, 2048];
const bestSize = availableSizes
    .filter(size => size <= minSide)
    .reduce((a, b) => Math.max(a, b), availableSizes[0]);
const finalSize = Math.min(targetWidth, targetHeight, bestSize);
  • availableSizes(対応するサイズ)を基準に、minSide より小さい最大のサイズを bestSize に設定。
  • targetWidthtargetHeight と比較し、最終的なサイズを決定。

5. クロップ開始位置の計算

let startX = 0;
let startY = 0;
switch (cropPosition) {
    case "center":
        startX = (originalWidth - finalSize) / 2;
        startY = (originalHeight - finalSize) / 2;
        break;
    case "right-top":
        startX = originalWidth - finalSize;
        startY = 0;
        break;
    case "center-top":
        startX = (originalWidth - finalSize) / 2;
        startY = 0;
        break;
    case "left-bottom":
        startX = 0;
        startY = originalHeight - finalSize;
        break;
    case "right-bottom":
        startX = originalWidth - finalSize;
        startY = originalHeight - finalSize;
        break;
    default: // "left-top"
        startX = 0;
        startY = 0;
}
  • cropPosition の値に応じて、クロップの開始位置を計算する。
  • 例えば "center" を選んだ場合、画像の中央を基準にクロップ。

6. canvas を使って画像をクロップ&リサイズ

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = finalSize;
canvas.height = finalSize;
ctx.drawImage(img, startX, startY, finalSize, finalSize, 0, 0, finalSize, finalSize);
  • canvas を作成し、その contextctx)を取得。
  • 計算した startX, startY を使って、クロップした画像を drawImage で描画。

7. canvasBlob に変換

canvas.toBlob((blob) => {
    if (blob) {
        resolve({
            url: URL.createObjectURL(blob),
            width: finalSize,
            height: finalSize,
            originalName: file.name.replace(/\.[^/.]+$/, ""),
            originalWidth,
            originalHeight
        });
    } else {
        reject(new Error('画像の変換に失敗しました'));
    }
}, 'image/png');
  • canvas.toBlob() を使い、画像を PNG の Blob に変換。
  • URL.createObjectURL(blob) を使い、ブラウザで表示できる URL を生成。
📌 React の Hooks について

このファイルでは useStateuseEffect などの React Hooks は使っていません
Hooks は Home.jsuseState を使い、resizedImages を管理するのに使われています。

例えば Home.js では:

const [resizedImages, setResizedImages] = useState([]);
  • useStateresizedImages の状態を管理し、setResizedImages() で更新。

この resizeImage.js は、React とは独立した 純粋な JavaScript 関数 なので、Hooks は不要です! 🚀

ポイント

指定サイズよりも大きくならないように制御
画像を canvas に描画し、PNGとして出力
元のファイル名を維持

5.リサイズ&ダウンロード機能の実装

src/pages/Home.js を作成し、UIを実装します。

Home.js

import React, { useState, useRef } from 'react';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import { resizeImage } from '../../utils/resizeImage';

const Home = () => {
    const [resizedImages, setResizedImages] = useState([]);
    const fileInputRef = useRef(null);
    const [currentSize, setCurrentSize] = useState(null);
    const [cropPosition, setCropPosition] = useState("left-top");

    // 画像選択後にリサイズ
    const handleResize = async (event) => {
        const files = Array.from(event.target.files);
        if (files.length === 0 || !currentSize) return;

        const resized = await Promise.all(
            files.map(async (file) => {
                return await resizeImage(file, currentSize, currentSize, cropPosition);
            })
        );

        setResizedImages(resized);
    };

    // ファイル選択ダイアログを開く
    const triggerFileSelect = (size) => {
        setCurrentSize(size);
        fileInputRef.current?.click();
    };

    // ZIP ダウンロード
    const downloadAllAsZip = async () => {
        if (resizedImages.length === 0) return;

        const zip = new JSZip();
        const folder = zip.folder('resized_images');

        await Promise.all(
            resizedImages.map(async ({ url, originalName, width, height }) => {
                const response = await fetch(url);
                const blob = await response.blob();
                folder.file(`${originalName}_${width}x${height}.png`, blob);
            })
        );

        zip.generateAsync({ type: 'blob' }).then(content => saveAs(content, 'resized_images.zip'));
    };

    return (
        <div>
            <h2>画像リサイズアプリ</h2>
            <label>
                <strong>クロップ基準を選択:</strong>
                <select value={cropPosition} onChange={(e) => setCropPosition(e.target.value)}>
                    <option value="left-top">左上</option>
                    <option value="center">中心</option>
                    <option value="right-bottom">右下</option>
                </select>
            </label>

            <button onClick={() => triggerFileSelect(128)}>128×128</button>
            <input type="file" multiple ref={fileInputRef} style={{ display: 'none' }} onChange={handleResize} />

            {resizedImages.length > 0 && <button onClick={downloadAllAsZip}>すべてZIPでダウンロード</button>}
        </div>
    );
};

export default Home;
Home.jsの詳しい解説を見る

Home.js は、React の関数コンポーネントを使って、画像のリサイズとクロップ処理を行う UI を実装したものです。
React Hooks(useState, useRef)を活用して、状態管理やユーザーインタラクション を制御しています。

この Home.js では、以下の React Hooks を使用しています。

1. useState:状態管理

const [resizedImages, setResizedImages] = useState([]);
const [currentSize, setCurrentSize] = useState(null);
const [cropPosition, setCropPosition] = useState("left-top");
  • resizedImages:リサイズ後の画像を管理(初期値:空配列 [])。
  • currentSize:選択されたリサイズのサイズを管理(初期値:null)。
  • cropPosition:クロップの基準位置を管理(初期値:left-top)。
✅ 変更方法
  • setResizedImages(newImages):リサイズ後の画像リストを更新
  • setCurrentSize(size):選択されたサイズを更新
  • setCropPosition(e.target.value):選択されたクロップ基準を更新

2. useRef:DOM要素を参照

const fileInputRef = useRef(null);
  • <input type="file"> の参照を保持するために useRef を使用。
if (fileInputRef.current) {
    fileInputRef.current.click();
}
  • triggerFileSelect 内で、選択されたサイズに応じて ファイル入力を自動的に開く

3. handleResize:画像をリサイズする非同期関数

const handleResize = async (event) => {
    const files = Array.from(event.target.files);
    if (files.length === 0 || !currentSize) return;
    const resized = await Promise.all(
        files.map(async (file) => {
            const { url, width, height, originalName, originalWidth, originalHeight } = 
                await resizeImage(file, currentSize, currentSize, cropPosition);
            return { url, width, height, originalName, originalWidth, originalHeight };
        })
    );
    setResizedImages(resized);
};
  • 選択された 複数の画像をリサイズ し、resizedImages に保存。
  • resizeImage()resizeImage.js の関数を呼び出してリサイズ処理を行う。

まとめ

  • useState で状態を管理し、setState を使って UI を更新。
  • useRef<input> の参照を取得し、プログラムで操作。
  • handleResize で画像をリサイズし、非同期で処理。

これで React Hooks を活用した画像リサイズアプリの基本構造 が理解できました!

7. 動作確認

npm start でアプリを起動し、ボタンを押して画像をリサイズ&ダウンロードできるか確認しましょう!

画像を指定サイズにリサイズ
元画像より大きくならないように制御
個別 or ZIPで一括ダウンロード対応

クロップ時に、元画像より大きいサイズに拡大しないように調整 する必要があります。

ルール

  1. 元画像の小さい辺(最小の高さ or 幅)を基準にする
    • 例:900×600 → min(900, 600) = 600
  2. 指定サイズ(128, 256, 512, 1024, 2048)と比較して、適切なサイズを決める
    • 例:1024 にしたいが、最大サイズが 600×600 しか取れない → 600×600 にクロップ
  3. クロップ(切り取る)起点を選択して正方形を切り取る

UI編集

Material UI のインストール

ターミナルで以下のコマンドを実行してください。

(プロジェクトの frontend ディレクトリに移動してから実行するのを忘れずに)

npm install @mui/material @mui/icons-material @emotion/react @emotion/styled

最終コード

Home.js

import React, { useState, useRef } from 'react';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import { resizeImage } from '../../utils/resizeImage';
import { Container, Grid, Typography, Button, Paper, Box, MenuItem, Select, FormControl, InputLabel } from "@mui/material";

const Home = () => {
    const [resizedImages, setResizedImages] = useState([]);
    const fileInputRef = useRef(null);
    const [currentSize, setCurrentSize] = useState(""); // 選択されたサイズ
    const [cropPosition, setCropPosition] = useState("left-top");
    const [selectedFiles, setSelectedFiles] = useState([]); // 選択された画像ファイル

    const availableSizes = [128, 256, 512, 1024, 2048]; // 選択可能なサイズ

    // ファイル選択時に状態を更新
    const handleFileChange = (event) => {
        const files = Array.from(event.target.files);
        setSelectedFiles(files);
        setResizedImages([]); // 画像を変更したらリサイズ結果をクリア
    };

    // 画像をリサイズ
    const handleResize = async () => {
        if (selectedFiles.length === 0 || !currentSize) return;

        const resized = await Promise.all(
            selectedFiles.map(async (file) => {
                return await resizeImage(file, currentSize, currentSize, cropPosition);
            })
        );

        setResizedImages(resized);
    };

    // ファイル選択ダイアログを開く
    const triggerFileSelect = () => {
        fileInputRef.current?.click();
    };

    // ZIP ダウンロード
    const downloadAllAsZip = async () => {
        if (resizedImages.length === 0) return;

        const zip = new JSZip();
        const folder = zip.folder('resized_images');

        await Promise.all(
            resizedImages.map(async ({ url, originalName, width, height }) => {
                const response = await fetch(url);
                const blob = await response.blob();
                folder.file(`${originalName}_${width}x${height}.png`, blob);
            })
        );

        zip.generateAsync({ type: 'blob' }).then(content => saveAs(content, 'resized_images.zip'));
    };

    return (
        <Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
            {/* タイトル */}
            <Typography variant="h4" align="center" gutterBottom>
                画像リサイズアプリ
            </Typography>

            {/* 機能の説明 */}
            <Typography variant="body1" align="center" paragraph>
                このアプリでは、簡単に画像をリサイズできます。GoogleのAPIを活用し、フロントエンドのみで完結します。
            </Typography>

            {/* アップロードボタン */}
            <Box display="flex" justifyContent="center" my={2}>
                <input type="file" accept="image/*" multiple hidden ref={fileInputRef} onChange={handleFileChange} />
                <Button variant="contained" color="primary" size="large" onClick={triggerFileSelect}>
                    画像をアップロード
                </Button>
            </Box>

            {/* クロップの起点選択 */}
            <Box display="flex" justifyContent="center" my={2}>
                <FormControl sx={{ minWidth: 200 }}>
                    <InputLabel>クロップの起点</InputLabel>
                    <Select value={cropPosition} onChange={(e) => setCropPosition(e.target.value)}>
                        <MenuItem value="left-top">左上</MenuItem>
                        <MenuItem value="center-top">中央上</MenuItem>
                        <MenuItem value="right-top">右上</MenuItem>
                        <MenuItem value="center">中央</MenuItem>
                        <MenuItem value="right-bottom">右下</MenuItem>
                        <MenuItem value="left-bottom">左下</MenuItem>
                    </Select>
                </FormControl>
            </Box>

            {/* サイズ選択 */}
            <Box display="flex" justifyContent="center" my={2}>
                <FormControl sx={{ minWidth: 200 }}>
                    <InputLabel>リサイズするサイズ</InputLabel>
                    <Select value={currentSize} onChange={(e) => setCurrentSize(e.target.value)}>
                        {availableSizes.map((size) => (
                            <MenuItem key={size} value={size}>{size} x {size}</MenuItem>
                        ))}
                    </Select>
                </FormControl>
            </Box>

            {/* リサイズボタン */}
            <Box display="flex" justifyContent="center" my={2}>
                <Button 
                    variant="contained" 
                    color="success" 
                    onClick={handleResize} 
                    disabled={selectedFiles.length === 0 || !currentSize}
                >
                    リサイズ
                </Button>
            </Box>

            {/* リサイズ後の画像を表示 */}
            {resizedImages.length > 0 && (
                <>
                    <Typography variant="h6" align="center" mt={4}>
                        リサイズ後の画像
                    </Typography>
                    <Grid container spacing={2} justifyContent="center">
                        {resizedImages.map(({ url, originalName, width, height }, index) => (
                            <Grid item key={index} xs={12} sm={6} md={4} lg={3}>
                                <Paper elevation={3} sx={{ p: 2, textAlign: "center" }}>
                                    <img src={url} alt={originalName} style={{ maxWidth: "100%", height: "auto" }} />
                                    <Typography variant="body2">
                                        {originalName} ({width}x{height})
                                    </Typography>
                                    <Button 
                                        variant="outlined" 
                                        color="primary" 
                                        size="small" 
                                        sx={{ mt: 1 }} 
                                        onClick={() => saveAs(url, `${originalName}_${width}x${height}.png`)}
                                    >
                                        ダウンロード
                                    </Button>
                                </Paper>
                            </Grid>
                        ))}
                    </Grid>

                    {/* ZIP ダウンロードボタン */}
                    <Box display="flex" justifyContent="center" my={4}>
                        <Button variant="contained" color="secondary" onClick={downloadAllAsZip}>
                            すべてダウンロード(ZIP)
                        </Button>
                    </Box>
                </>
            )}

            {/* レイアウト:説明セクション モーダルで表示? */}
            <Grid container spacing={3}>
                {/* 左側の説明 */}
                <Grid item xs={12} md={6}>
                    <Paper elevation={3} sx={{ p: 3 }}>
                        <Typography variant="h6" gutterBottom>
                            使い方
                        </Typography>
                        <Typography variant="body2">
                            1. 画像をアップロード <br />
                            2. クロップの起点を選択 <br />
                            3. サイズを選択 <br />
                            4. リサイズ <br />
                            5. ダウンロード!
                        </Typography>
                    </Paper>
                </Grid>

                {/* 右側の特徴 */}
                <Grid item xs={12} md={6}>
                    <Paper elevation={3} sx={{ p: 3 }}>
                        <Typography variant="h6" gutterBottom>
                            特徴
                        </Typography>
                        <Typography variant="body2">
                            ✅ フロントエンドのみで処理 <br />
                            ✅ 高速な画像変換 <br />
                            ✅ シンプルなUI
                        </Typography>
                    </Paper>
                </Grid>
            </Grid>
        </Container>
    );
};

export default Home;

resizeImage.js

export const resizeImage = (file, targetWidth, targetHeight, cropPosition = "left-top") => {
    return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => {
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');

            const originalWidth = img.width;
            const originalHeight = img.height;

            // クロップサイズを決定(短い辺を基準に)
            const minSide = Math.min(originalWidth, originalHeight);
            const availableSizes = [128, 256, 512, 1024, 2048];
            const bestSize = availableSizes.filter(size => size <= minSide).reduce((a, b) => Math.max(a, b), availableSizes[0]);

            const finalSize = Math.min(targetWidth, targetHeight, bestSize);

            // キャンバスの設定
            canvas.width = finalSize;
            canvas.height = finalSize;

            // クロップの開始位置を計算
            let startX = 0, startY = 0;
            switch (cropPosition) {
                case "center":
                    startX = (originalWidth - finalSize) / 2;
                    startY = (originalHeight - finalSize) / 2;
                    break;
                case "right-top":
                    startX = originalWidth - finalSize;
                    startY = 0;
                    break;
                case "center-top":
                    startX = (originalWidth - finalSize) / 2;
                    startY = 0;
                    break;
                case "left-bottom":
                    startX = 0;
                    startY = originalHeight - finalSize;
                    break;
                case "right-bottom":
                    startX = originalWidth - finalSize;
                    startY = originalHeight - finalSize;
                    break;
                default: // "left-top"
                    startX = 0;
                    startY = 0;
            }

            // 画像をクロップして描画
            ctx.drawImage(img, startX, startY, finalSize, finalSize, 0, 0, finalSize, finalSize);

            // 画像を Blob に変換
            canvas.toBlob((blob) => {
                if (blob) {
                    resolve({
                        url: URL.createObjectURL(blob),
                        width: finalSize,
                        height: finalSize,
                        originalName: file.name.replace(/\.[^/.]+$/, ""),
                        originalWidth,
                        originalHeight
                    });
                } else {
                    reject(new Error('画像の変換に失敗しました'));
                }
            }, 'image/png');
        };

        img.onerror = (err) => reject(err);

        const reader = new FileReader();
        reader.onload = (e) => (img.src = e.target.result);
        reader.readAsDataURL(file);
    });
};

-Apps