Flutterで、写真を撮影するフォームを作り、その撮影した写真をバックエンドであるPythonに送り、Pythonの画像加工の技術を使って、セピア色にするのを作っていきたいと思います。
エディターは、Android Studioを使っていきます。
Android StudioにFlutterの環境構築をするには、 こちらのサイトを参考にするといいです↓
https://note.com/qkuronekop/n/nb78b7c642d6a
Flutterの環境構築ができましたら、プロジェクトを作成していきます。
今回のプロジェクト名は、photoにします。
とりあえず、まずは、写真が撮影できるアプリを作成します。
実装では、以下のパッケージ
・camera: デバイス上のカメラを操作するのに必要
・path provider: 画像の保存場所を見つけてくれる
・path: 画像の保存先を作成してくれる
・video player: ビデオを再生してくれる
が必要となるので、コマンドで、
$ flutter pub get
$ flutter pub add path_provider
$ flutter pub add path
$ flutter pub add video_player
※ $マークは、コマンドライン(ターミナル)での入力を意味しているので、無視してください。
を打ってください。
android/app/build.gradleファイルのminSdkVersionを以下のように変更します。
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
android {
namespace "com.example.photo"
compileSdk flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.example.photo"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
- minSdkVersion flutter.minSdkVersion
+ minSdkVersion 21
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}
dependencies {}
にします。
そして、カメラ機能を使う際に、許可を要求するための文言を追加します。
そのために、
ios/Runner/Info.plistを
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Photo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>photo</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
+ <key>NSCameraUsageDescription</key>
+ <string>撮影をするために、カメラ機能を使う必要があります。</string>
+ <key>NSMicrophoneUsageDescription</key>
+ <string>撮影をする際に、マイク機能を使う場合があります。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
というふうに変更します。
次に、lib/main.dartを、
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
// Flutterのバインディングを初期化する。この関数を呼び出すことで、Flutterが完全に起動したことが保証される
WidgetsFlutterBinding.ensureInitialized();
// 使用可能なカメラを取得する非同期処理
final cameras = await availableCameras();
// 最初のカメラデバイスを取得(一般的には、デバイスに複数のカメラがある場合、最初に使うカメラを選択)
final firstCamera = cameras.first;
// 取得できているか確認
print(firstCamera);
// アプリケーションを実行する
runApp(MyApp(camera: firstCamera));
}
class MyApp extends StatelessWidget {
const MyApp({
Key? key,
required this.camera,
}) : super(key: key);
// カメラ情報(デバイスのカメラ)
final CameraDescription camera;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'カメラ機能',
theme: ThemeData(),
home: TakePictureScreen(camera: camera),
);
}
}
// 写真撮影画面
class TakePictureScreen extends StatefulWidget {
const TakePictureScreen({
Key? key,
required this.camera, // 使用するカメラ情報を受け取る
}) : super(key: key);
// カメラ情報
final CameraDescription camera;
@override
TakePictureScreenState createState() => TakePictureScreenState();
}
class TakePictureScreenState extends State<TakePictureScreen> {
late CameraController _controller; // カメラの操作を管理するコントローラー
late Future<void> _initializeControllerFuture; // カメラの初期化を待つ
@override
void initState() {
super.initState();
// CameraControllerを初期化。使用するカメラと解像度を指定。
_controller = CameraController(
// 受け取ったカメラ情報を使用
widget.camera,
// カメラの解像度設定
ResolutionPreset.medium,
);
// カメラの初期化処理を開始し、その完了を待つ
_initializeControllerFuture = _controller.initialize();
}
@override
void dispose() {
// 画面が破棄される際にカメラコントローラーも破棄してリソースを解放
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: FutureBuilder<void>(
// カメラ初期化が完了するのを待つ
future: _initializeControllerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
// 初期化が完了したらカメラプレビューを表示
return CameraPreview(_controller);
} else {
// 初期化中はローディングインジケーターを表示
return const CircularProgressIndicator();
}
},
),
),
// 写真を撮るためのボタン
floatingActionButton: FloatingActionButton(
onPressed: () async {
// 写真を撮る(非同期処理)
final image = await _controller.takePicture();
// 写真を撮った後、次の画面へ遷移(撮影した画像を表示)
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DisplayPictureScreen(imagePath: image.path),
fullscreenDialog: true,
),
);
},
child: const Icon(Icons.camera_alt),// カメラアイコン
),
);
}
}
// 撮影した写真を表示する画面
class DisplayPictureScreen extends StatelessWidget {
const DisplayPictureScreen({Key? key, required this.imagePath})
: super(key: key);
// 画像のファイルパス
final String imagePath;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('この写真でいいですか?')),
body: Center(
// 画面中央に撮影した画像を表示
child: Image.file(File(imagePath))),
);
}
}
にします。
それでは、さっそくテストをしてみましょう。
実機は、お使いのパソコンとスマホを使い、下のような専用のコードで、同期させます。
今回は、iphoneを使います。
そして、この開発ツールとしてXCodeを使います。
XCodeの使い方やテストをする際のスマホの環境設定については、
こちらのサイトを参考にしてみてください↓
FlutterやCocoaPodsの依存関係の影響で、テストをする際にエラーが発生する場合があります。
その場合は、
ターミナルで、
$ flutter clean
$ flutter pub get
と入力し、Flutterのキャッシュと依存関係を再取得します。
次に、iOSの依存関係を管理する CocoaPods のインストールを確認します。
CocoaPodsがインストールされていない場合、インストールする方法を以下に記述します。
前提条件
・macOSがインストールされていること。
・Xcodeがインストールされていること。
CocoaPodsのインストール手順
Homebrewを使ったインストール(推奨) HomebrewはmacOS向けのパッケージマネージャーで、CocoaPodsもHomebrewを使ってインストールできます。
もしHomebrewがインストールされていない場合は、まずHomebrewをインストールしてください。
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
次に、Homebrewを使ってCocoaPodsをインストールします。
$ brew install cocoapods
インストール後、podコマンドが使えるようになります。インストールが成功したことを確認するために、次のコマンドを実行してバージョンを確認できます。
$ pod --version
バージョン番号が表示されれば、インストールは正常に完了しています。
もしHomebrewを使わずにインストールしたい場合は、以下のコマンドを実行します。
sudo gem install cocoapods
インストール後、以下のコマンドでバージョンを確認できます。
$ pod --version
これでpodコマンドが使えるようになります。
CocoaPodsの初期設定
インストール後、最初にCocoaPodsをセットアップするために、以下のコマンドを実行します。
$ pod setup
これにより、CocoaPodsの依存関係を管理するためのローカルリポジトリが作成されます。この操作は初回のみに必要です。
CocoaPodsのキャッシュをクリアしてから、pod install を再度実行してみてください。
やり方は、
ターミナルで、
$ cd ios
$ pod cache clean --all
$ pod install
というコマンドを入力します。
Xcodeの設定を確認
Xcodeの設定で、iOSプロジェクトが適切にビルドされるか確認します。
Xcodeで ios/Runner.xcworkspace を開きます。
$ open ios/Runner.xcworkspace
※ iosディレクトリ内にいる場合は、ios/の部分は入りません。
Xcodeでプロジェクトをクリーンビルドします。
メニューから Product > Clean Build Folder を選択。
その後、Product > Build を実行します。
これで、エラーが解決されるかと思います。
下の画面のように、
(Build Succeeded)という表示が出れば、成功です。
そうなりましたら、
ターミナルで、
$ flutter run
と打ち、
というようなメッセージが出れば、成功です。
こうしたメッセージが出ると、
このように、スマホに映し出され、右下のカメラボタンを押すと、
という画面に切り替われば成功です。
これが第一段階です。
これを発展させて、Djangoと連携して、ノスタルジックなセピア色の写真に変えるアプリにしていきます。
今度は、以下のパッケージが必要になります。
・image_gallery_saver: アプリ内で撮影した画像やダウンロードした画像を簡単にデバイスのギャラリー(画像フォルダ)に保存してくれます。
・http: Web サーバーとの通信や、外部 API とのやり取りが簡単に行えるようにする。
$ flutter pub add image_gallery_saver
$ flutter pub add http
と、コマンドで打ってください。
その後に、
ios/Runner/info.plistに、以下の文を追記します。
<key>NSCameraUsageDescription</key>
<string>撮影をするために、カメラ機能を使う必要があります。</string>
<key>NSMicrophoneUsageDescription</key>
<string>撮影をする際に、マイク機能を使う場合があります。</string>
+ <key>NSPhotoLibraryAddUsageDescription</key>
+ <string>撮影した写真を保存します</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
これらの記載をすると、
アプリを起動させた時、
というメッセージが出ます。
こちらのターミナルで、
アプリをまとめるディレクトリを作成していきます。
今回は、Cameraというアプリにしたいので、
$ mkdir Camera
と入力し、
$ cd Camera
で、Cameraディレクトリに入り、PythonのフレームワークであるDjangoをインストールします。
Djangoのインストールの仕方は、こちらのサイトを参考にするといいです。↓
Pythonで、撮影した写真を加工するためのファイルを作りたいので、
Djangoアプリを作成するために、以下のコマンドを入力します。
今回は、pythonという名前のプロジェクトを作成します。
$ django-admin startproject python
すると、ディレクトリ構造は、
python
├── manage.py
└── python
├── __init__.py
├── asgi.py
├── settings.py
├── urls.py
└── wsgi.py
となります。
そして、この中にアプリケーションを作成します。
撮影した写真を加工するprocessというアプリケーションを作りたいので、
アプリケーションを作成するには、manage.pyと同じディレクトリに入り、
$python manage.py startapp process
というコマンドを打ってください。
すると、ディレクトリ構造は、
python
├── manage.py
├── process
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ │ └── __init__.py
│ ├── models.py
│ ├── tests.py
│ └── views.py
└── python
├── __init__.py
├── __pycache__
│ ├── __init__.cpython-311.pyc
│ └── settings.cpython-311.pyc
├── asgi.py
├── settings.py
├── urls.py
└── wsgi.py
という構造に変わります。
パスを通すために、python/python/urls.pyに、
"""processed_photo URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("process/", include("process.urls")),
]
に変更。
そして、processディレクトリ内に、urls.pyファイルを作り、パスを通すために、以下のコードを書きます。
from django.urls import path
from process import views
urlpatterns = [
path("request/", views.CameraRequest, name="camera_request"),
]
processディレクトリの中のviews.pyに撮影した写真を送り、セピア色に加工するためのコードを書いていきます。
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from PIL import Image
import io
def processed_with_sepia(image_file):
# セピア色にする加工
RGBImage = Image.open(image_file).convert("RGB")
width, height = RGBImage.size
sepia_image = Image.new("RGB", (width, height))
for y in range(height):
for x in range(width):
r,g,b = RGBImage.getpixel((x, y))
# セピア調の色合いに変化
new_r = int(r * 0.393 + g * 0.769 + b * 0.189)
new_g = int(r * 0.349 + g * 0.686 + b * 0.168)
new_b = int(r * 0.272 + g * 0.534 + b * 0.131)
# RGB値が255を超えないように調整
new_r = min(new_r, 255)
new_g = min(new_g, 255)
new_b = min(new_b, 255)
sepia_image.putpixel((x,y), (new_r, new_g, new_b))
return sepia_image
@csrf_exempt
def CameraRequest(request):
if request.method == 'POST' and request.FILES.get('photo'):
image_file = request.FILES.get('photo')
if image_file:
# デバッグ情報を表示するためにprint文を追加
print('デバッグのコード:受け取った画像のファイル名 = ', image_file.name)
# 画像を受け取って処理するコードを追加します
processed_image = processed_with_sepia(image_file)
with io.BytesIO() as output:
processed_image.save(output,format='PNG')
processed_image_binary = output.getvalue()
# レスポンスとして処理済みの画像を返します
response = HttpResponse(processed_image_binary, content_type = "image/png")
response['Content-Disposition'] = 'attachment; filename="photo.png"'
return response
return HttpResponseBadRequest()
この作業が終わると、撮影した写真をFlutterからDjangoに渡すために、
lib/main.dartのコードを書き換えていきます。
FlutterからDjangoに写真を送る際、下の画像のように、
反時計回りに90度傾いた状態で、渡されてしまいます。
バックエンドであるDjango側でその画像を元の傾きに戻す処理を行うと、
画像が見切れてしまうため、
フロントエンドであるFlutter側で再度、元の傾きに戻す処理を行います。
このことを念頭に、Android Studioに戻り、
photoプロジェクト内の
lib/main.dartを以下のように書き換えます。
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:image_gallery_saver/image_gallery_saver.dart';
Future <void> main() async {
// Flutterのバインディングを初期化する。この関数を呼び出すことで、Flutterが完全に起動したことが保証される
WidgetsFlutterBinding.ensureInitialized();
// 使用可能なカメラを取得する非同期処理
final cameras = await availableCameras();
// 最初のカメラデバイスを取得(一般的には、デバイスに複数のカメラがある場合、最初に使うカメラを選択)
final firstCamera = cameras.first;
runApp(MyApp(camera: firstCamera));
}
class MyApp extends StatelessWidget {
const MyApp({
Key? key,
required this.camera,
}) : super(key: key);
final CameraDescription camera;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'カメラ機能',
theme: ThemeData(),
home: TakePictureScreen(camera: camera),
);
}
}
class TakePictureScreen extends StatefulWidget {
const TakePictureScreen({
super.key,
required this.camera,
});
final CameraDescription camera;
@override
TakePictureScreenState createState() => TakePictureScreenState();
}
class TakePictureScreenState extends State<TakePictureScreen> {
late CameraController _controller;
late Future<void> _initializeControllerFuture;
@override
void initState() {
super.initState();
_controller = CameraController(
// 受け取ったカメラ情報を使用
widget.camera,
// 解像度を定義
ResolutionPreset.medium,
);
// カメラの初期化処理を開始し、その完了を待つ
_initializeControllerFuture = _controller.initialize();
}
@override
void dispose() {
// 画面が破棄される際にカメラコントローラーも破棄してリソースを解放
_controller.dispose();
super.dispose();
}
Future<void> _sendPhoto(File photo) async {
var request = http.MultipartRequest(
'POST',
Uri.parse('http://IPアドレス(例:0.0.0.0:8000/process/request/'),);
request.files.add(http.MultipartFile.fromBytes('photo', await photo.readAsBytes(), filename: 'photo.png',));
try {
var response = await request.send();
if (response.statusCode == 200) {
var contentBytes = await response.stream.toBytes();
var base64Image = base64Encode(contentBytes);
//表示用の画面に移動
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DisplayProcessedPictureScreen(imagePath: 'data:image/png;base64,$base64Image'),
fullscreenDialog : true,
),
);
} else {
// 失敗時の処理
print('デバッグのコード:レスポンスステータスコード = ${response.statusCode}');
}
} catch (e) {
// エラー時の処理
print('デバッグのコード: エラーが発生しました: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
// FutureBuilderで初期化を待ってからプレビューを表示 (それまではインジケータを表示)
child: FutureBuilder<void>(
future: _initializeControllerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return CameraPreview(_controller);
} else {
return const CircularProgressIndicator();
}
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// 写真を撮る
final image = await _controller.takePicture();
await _sendPhoto(File(image.path));
// 表示用の画面に遷移
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => DisplayPictureScreen(imagePath: image.path),
fullscreenDialog: false,
),
);
},
child: const Icon(Icons.camera_alt),
),
);
}
}
// 撮影した写真を表示する画面
class DisplayPictureScreen extends StatelessWidget {
const DisplayPictureScreen({super.key, required this.imagePath}) ;
final String imagePath;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.blue,
title: const Text(
'通常',
style: TextStyle(
color: Colors.white, // 文字の色を白色に設定
fontWeight: FontWeight.bold, // 文字を太字
),
),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.file(File(imagePath)),
SizedBox(
height: 20,
width: 80),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
// 「保存する」ボタンを押した時の処理
_saveImage(context, imagePath);
},
child: Text(
'保存する',
style: TextStyle(
color: Colors.white, //テキストを白字に設定
fontWeight: FontWeight.bold, //テキストを太字
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.blue),
)
),
SizedBox(
height: 20,
width: 80),
ElevatedButton(
onPressed: () {
// 「撮り直す」ボタンが押された時の処理
Navigator.of(context).pop();
},
child: Text(
'撮り直す',
style: TextStyle(
color: Colors.white, //テキストを白字に設定
fontWeight: FontWeight.bold, //テキストを太字
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.green),
)
),
],
),
],
),
),
);
}
Future<void> _saveImage(BuildContext context, String imagePath) async {
try {
final result =await ImageGallerySaver.saveFile(imagePath);
if (result != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('画像が保存されました')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('画像の保存に失敗しました')),
);
}
} catch (e) {
print('画像の保存時にエラーが発生しました: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('画像の保存時にエラーが発生しました')),
);
}
}
}
// 処理後の画像を受け取る
class DisplayProcessedPictureScreen extends StatefulWidget {
final String imagePath;
const DisplayProcessedPictureScreen({super.key, required this.imagePath});
@override
DisplayProcessedPictureScreenState createState() =>
DisplayProcessedPictureScreenState();
}
class DisplayProcessedPictureScreenState extends State<DisplayProcessedPictureScreen> {
Future<http.Response>? _fetchImageFuture;
@override
void initState() {
super.initState();
_fetchImage();
}
Future<void> _fetchImage() async {
final base64Image = widget.imagePath
.split(',')
.last;
final decodedBytes = base64Decode(base64Image);
setState(() {
_fetchImageFuture =
Future.value(http.Response.bytes(decodedBytes, 200, headers: {
HttpHeaders.contentTypeHeader: 'image/png',
}));
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.redAccent,
title: const Text(
'ノスタルジック',
style: TextStyle(
color: Colors.white, // 文字の色を白に設定
fontWeight: FontWeight.bold, // 文字を太字
))),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FutureBuilder<http.Response>(
future: _fetchImageFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
final response = snapshot.data!;
if (response.statusCode == 200) {
final responseData = response.bodyBytes;
return Transform.rotate(
angle: 90 * pi /180,
child:// 反時計回りに90度回転
Image.memory(
responseData,
fit: BoxFit
.contain,
), // 画像をウィジェット内に適切に表示させるために適応させるfitパラメータを追加
alignment: Alignment.center, //回転の中心を設定
);
} else {
// レスポンスが不正なステイタスコードを返した場合のエラーメッセージ
return Text('画像の取得中にエラーが発生しました。');
}
}
return const CircularProgressIndicator();
},
),
SizedBox(height: 80),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
// 「保存する」ボタンを押した時の処理
_savedImage(context, widget.imagePath);
},
child: Text(
'保存する',
style: TextStyle(
color: Colors.white, // テキストを白字に設定
fontWeight: FontWeight.bold, // テキストを太字
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.blue), //ボタンの背景色を青色に設定
),
),
],
),
SizedBox(height: 10),
ElevatedButton(
onPressed: () {
// 「撮り直す」ボタンが押された時の処理
Navigator.of(context).pop();
},
child:Text(
'破棄',
style: TextStyle(
color: Colors.white, // テキストを白色に設定
fontWeight: FontWeight.bold, // テキストを太字
),
),
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(Colors.red), //ボタンの背景色を赤色に設定
)
),
],
),
),
);
}
Future<void> _savedImage(BuildContext context, String imagePath) async {
try {
final base64Image = widget.imagePath
.split(',')
.last;
final decodedBytes = base64Decode(base64Image);
final rotatedBytes = await _rotatedImage(decodedBytes, 90);
// 画像を保存
final result = await ImageGallerySaver.saveImage(rotatedBytes);
if (result != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('画像が保存されました')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('画像の保存に失敗しました')),
);
}
} catch (e) {
print('画像の保存時にエラーが発生しました: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('画像の保存時にエラーが発生しました')),
);
}
}
Future<Uint8List> _rotatedImage(Uint8List imageBytes, double angle) async {
// 画像をデコード
final image = await decodeImageFromList(imageBytes);
// 回転後のサイズを計算
final double radians = angle * ( pi / 180);
final double sine = sin(radians);
final double cosin = cos(radians);
final double newWidth = (image.width * cosin + image.height * sine).abs();
final double newHeight = (image.width * sine + image.height * cosin).abs();
// 新しいサイズのキャンバスを作成
final recorder = PictureRecorder();
final canvas = Canvas(recorder, Rect.fromPoints(Offset(0.0,0.0), Offset(newWidth, newHeight)));
// 画像を描画して回転
canvas.translate(newWidth / 2, newHeight / 2);
canvas.rotate(radians);
canvas.drawImage(image, Offset(-image.width / 2, -image.height / 2), Paint());
// キャンバスを終了し、画像を取得
final picture = recorder.endRecording();
final img = await picture.toImage(newWidth.toInt(), newHeight.toInt());
final byteData = await img.toByteData(format: ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
}
最終的なinfo.plistは、
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Take Photo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>photo</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>撮影をするために、カメラ機能を使う必要があります。</string>
<key>NSMicrophoneUsageDescription</key>
<string>撮影をする際に、マイク機能を使う場合があります。</string>
+ <key>NSPhotoLibraryAddUsageDescription</key>
+ <string>撮影した写真を保存します</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
となり、
pubspec.yamlは、
name: photo
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: '>=3.3.4 <4.0.0'
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
camera: ^0.11.0+2
path_provider: ^2.1.5
path: ^1.9.0
video_player: ^2.9.2
image_gallery_saver: ^2.0.3
http: ^1.2.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^3.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages
というコードになっていれば、OKです。
では、この作成したphotoプロジェクトを
先ほど、作成したCameraディレクトリ内にコピーしていきます。
なので、photoプロジェクトのパスをAndroid Studio内のターミナルで、
$ pwd
と打ち、取得します。
※ 表示されたメッセージがphotoプロジェクトのパスになります。
そして、次に、Cameraディレクトリ内で、同じく、
$ pwd
と打ち、
$ cp -r [photoプロジェクトのパス] [Cameraディレクトリのパス/photo]
とコマンドで入力してください。
このようなディレクトリ構造になっていれば大丈夫です。
Camera
├── photo
└── python
└── process
より詳しい全体的なディレクトリ構造は、
Camera
├── python
│ ├── Dockerfile
│ ├── manage.py
│ ├── process
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── apps.py
│ ├── migrations
│ │ │ └── __init__.py
│ │ ├── models.py
│ │ ├── tests.py
│ │ ├── urls.py
│ │ └── views.py
│ └── python
│ ├── __init__.py
│ ├── __pycache__
│ │ ├── __init__.cpython-311.pyc
│ │ └── settings.cpython-311.pyc
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── photo # Flutterアプリケーション
となっていれば大丈夫です。
では、photoディレクトリ内で、
$ flutter run
を実行してみましょう。
このような画面が出てきてしまったら、
Components内にある
エラーメッセージで表示している必要としているデバイス(僕の場合は、iOS 18.2)を選び、「GET」ボタンを押してください。
これをすることで、エラーが解消されるかと思います。
では、これまでに書いたコードが動くかどうかテストをしていきます。
FlutterとDjangoは、手動で動かしていきます。
撮影した写真を加工するために、まずは、Djangoのサーバーを動かしていきます。
Djangoを動かす前に、
サーバーのCORS設定を確認(特にローカル開発環境)
モバイルデバイスからのリクエストをサーバーが受け入れるためには、サーバーのCORS(Cross-Origin Resource Sharing)設定が適切に設定されている必要があります。
特に、FlutterアプリがAndroidやiOS端末で実行されている場合、サーバー側でCORSが適切に設定されていないと、リクエストが拒否されることがあります。
例えば、DjangoでCORSを許可するには、django-cors-headersをインストールして設定します。
$ pip install django-cors-headers
次に、settings.pyで設定します。
INSTALLED_APPS = [
...
'corsheaders',
...
]
MIDDLEWARE = [
...
'corsheaders.middleware.CorsMiddleware',
...
]
# 必要に応じて設定(すべてのドメインからのリクエストを許可する場合)
CORS_ALLOW_ALL_ORIGINS = True
Pythonのsslモジュールが利用できないことが原因です。sslモジュールは、SSL/TLS接続を扱うために必要ですが、現在のPython環境ではこれが有効になっていない可能性があります。
- Pythonのsslモジュールのインストールを確認
この問題は、Pythonをインストールする際にsslサポートが無効化された場合に発生します。これを解決するために、次の手順を試してみてください。
- pyenvの再インストール
PythonのSSLサポートを有効にするために、pyenvでPythonを再インストールする際に、opensslを適切にリンクさせてインストールします。次のコマンドを試してください:
$ env PATH="$(brew --prefix openssl@3)/bin:$PATH" pyenv install 3.11.4
openssl@3を正しくリンクさせてPythonをインストールします。これにより、PythonがSSLモジュールを適切に利用できるようになります。
- pyenvのPythonバージョンを再設定
インストールが完了したら、pyenvでインストールしたPythonバージョンを使用するように設定します。
$ pyenv global 3.11.4
- Pythonの依存関係の再インストール
SSLモジュールが有効になったら、必要なパッケージを再インストールします。
$ pip install --upgrade pip
$ pip install django-cors-headers
これで、pipが正しくHTTPS経由でパッケージをダウンロードできるようになるはずです。
これらを踏まえて、python/python/settings.pyの全体的な内容は、
"""
Django settings for python project.
Generated by 'django-admin startproject' using Django 4.2.2.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-uvw0i=(例:########################)'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [IPアドレス(例:'0.0.0.0')]
# Application definition
INSTALLED_APPS = [
+ 'corsheaders',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
+ 'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'python.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'python.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+ # 必要に応じて設定(全てのドメインからのリクエストを許可する場合)
+ CORS_ALLOW_ALL_ORIGINS = True
に変わります。
では、Cameraディレクトリ内のpython(manage.pyがある)ディレクトリ内で、
$ python manage.py runserver IPアドレス(例:0.0.0.0:8000)
上のようなメッセージが、出れば成功です。
それでは、スマホとパソコンをコードで同期してください。
次は、フロントエンドであるFlutterを動かしていきたいので、photoディレクトリ内で、
$ flutter run
とコマンドを打ってください。
上のようなメッセージが出れば成功です。
すると、同期したスマホの画面が切り替わり、
という画面に変わります。
そして、また、カメラボタンを押すと、
今度は、上のような画面が現れるかと思います。
この画像に変われば、Djangoとの通信ができています。
というメッセージが出ます。
そして、写真アルバムを見てみると、
ちゃんと保存されていますね。
次に、「破棄」ボタンを押すと、
加工をしていない画面に遷移します。
こちらも、「保存する」ボタンを押すと、
というメッセージが出ます。
また、写真アルバムを確認すると、
こちらもちゃんと保存されています。
そして、「撮り直す」ボタンを押すと、
また、撮影画面に戻ります。
ここまでできれば、成功です。
これで完成です。
ここまでの工程は、Githubに上げておきます↓
もし、加工する写真をもっと赤味を強くしたり、青みがかったりしたい場合は、
python/process/views.pyの
new_r や new_g、new_bの数値を変えて、お好みの数値に設定してみてください。