神社 地域 にデジタルを

❝画像のトリミング❞のコーディング

2023年05月30日 | 最終更新日:2023年07月14日 | コーディング |
Django4 python3.10 javascript pillow wsl bootstrap crooper_js

cropper.jsを用いると、トリミングの実装ができるとわかり取り組みました。

アスペクト変更やサイズ変更といった操作を、WEB上でインタラクティブに操作できる実装に悩みましたが、以下の概要でコーディングしてほぼ意図した動きにできました。

データベースにはWEBP形式で保存し、WEBサイト上からのダウンロードはPNG形式とするように設計。

保存領域を軽量なWEBPにし、ダウンロードするユーザー側には後の扱いがしやすいPNG形式としました。

概要を記します。


事前準備(概略)

Djangoプロジェクトにアプリを作成

python manage.py startapp crop_images

<Poetryで仮想環境を準備(例)>事前にPoetryをインストールしておく

仮想環境をフォルダ直下にする

poetry config virtualenvs.in-project true
~$ mkdir o_poetry_dj
~$ cd o_poetry_dj/
~/o_poetry_dj$ poetry init

以下の設問にEnter、yes、noを入力

This command will guide you through creating your pyproject.toml config.
Package name [o_poetry_dj]:※Enterキー
Version [0.1.0]:※Enterキー
Description []:※Enterキー
Author [user , n to skip]:※Enterキー
License []:※Enterキー
Compatible Python versions [^3.10]:※Enterキー
Would you like to define your main dependencies interactively? (yes/no) [yes] no
Would you like to define your development dependencies interactively? (yes/no) [yes] no
Generated file
[tool.poetry]
name = "o_poetry_dj"
version = "0.1.0"
description = ""
authors = ["user "]
[tool.poetry.dependencies]
python = "^3.10"
[tool.poetry.dev-dependencies]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
Do you confirm generation? (yes/no) [yes] yes

poetry shell

<venvの場合(例)>※venvがインストールされていることを確認

python3 -m venv app_crop_image
source app_crop_image/bin/activate

以後、仮想環境で作業

必要なパッケージをインストール

(仮想名) poetry add pillow numpy 他

<pipの場合>

(仮想名) pip install pillow numpy 他

スーパーユーザーを作成

python manage.py createsuperuser

settings.py(config/settings.py)

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    "crop_images",
    ...省略...
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / "templates"],
        ...省略...

STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / "static"]

MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'

プロジェクトフォルダのsettings.pyに記述


urls.py(config/urls.py)

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [ path('admin/', admin.site.urls), path("crop_image/", include("crop_images.urls")), ...省略... if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

プロジェクトフォルダのurls.pyに記述


フォルダ構成


├── crop_images/
│   ├── admin.py
│   ├── apps.py
│   ├── forms.py
│   ├── __init__.py
│   ├── migrations/
│   ├── models.py
│   ├── __pycache__/
│   ├── tests.py
│   ├── urls.py
│   └── views.py

models.py


from django.db import models
from PIL import Image
import numpy as np
from io import BytesIO
from django.core.files.base import ContentFile

import uuid


def image_folder(instance, filename):
    return 'crop_images/{}.webp'.format(uuid.uuid4().hex)


class CropImage(models.Model):
    your_name = models.CharField(max_length=50, blank=True, null=True)
    file = models.ImageField(upload_to=image_folder)
    uploaded = models.DateTimeField(auto_now_add=True)    
    
    def __str__(self):
        return str(self.pk)
    
    def save(self, *args, **kwargs):
        
        # open image
        pil_img = Image.open(self.file)
        
        # 画像の横幅が1500pxより大きい場合はリサイズ
        if pil_img.width > 1500:
            # scale_to_width(pil_img)
            print("Yes")
            height = round(pil_img.height * 1500 / pil_img.width)
            print(height)
            pil_img = pil_img.resize((1500, height))
            print("Resize Ok")
        
        
        # save
        
        buffer = BytesIO()
        buffer.seek(0)
        pil_img.save(buffer, format="webp")
        image_png = buffer.getvalue()
        
        self.file.save(str(self.file), ContentFile(image_png), save=False)
        
        
        
        super().save(*args, **kwargs)

models.pyには以下の要素をもたせます。

  • your_nameのところは、管理サイトで検索しやすいように自分の名前などフィルタリングしやすいようにしておく。16行目
  • ファイル名はuuidを用いてセキュアに。11行目
  • アップロードされた画像が1500px以上の場合はリサイズ。29行目
  • 拡張子をwebpにしてデータベースに保存。63行目

admin.py

管理画面で操作できるように、modelsの内容を登録

from django.contrib import admin
from .models import CropImage
from django.utils.safestring import mark_safe
import os


class CropImageAdmin(admin.ModelAdmin):
    list_display = ("id", "omoto", "file", "uploaded", "custom_column", "cropimg_thumbnail")
    search_fields = ("omoto", )
    
    def custom_column(self, obj):
        # return '<a href="/media/' + str(obj.file) + '" download"' + str(obj.file) + '">' +\
        #     os.path.basename(str(obj.file)) + '</a>'
        return '<img src="/media/' + str(obj.file) + '" width="" style="">'
        
         
    custom_column.short_description = "Downloadリンク生成"
    
    def cropimg_thumbnail(self, obj):
        return mark_safe('<img src="{}" style="width:100px;height:auto">'.format(obj.file.url))



admin.site.register(CropImage, CropImageAdmin)

管理画面を便利に使うため、以下のことをしています。

  • custom_columnでimgタグをhtmlコードで出力(コピー・アンド・ペーストで使えるように)14行目
  • photo_thumbnailは、加工した後の画像が管理画面上に一覧表示される記述。20行目

forms.py

シンプルなモデルフォームを用意します。


from django import forms
from .models import CropImage


class CropImageForm(forms.ModelForm):
    class Meta:
        model = CropImage
        fields = ("file",)

urls.py


from django.urls import path
from .views import crop_image_main

app_name = "crop_images"


urlpatterns = [
    path("", crop_image_main, name="main"),
]

views.py


from django.shortcuts import render
from .models import CropImage
from .forms import CropImageForm
from django.http import JsonResponse
from PIL import Image
import os
import io
from django.core.files.uploadedfile import InMemoryUploadedFile


def crop_image_main(request):
    form = CropImageForm(request.POST or None, request.FILES or None)
    
    if form.is_valid():
        instance = form.save(commit=False)
        if request.user.is_superuser and request.user.last_name == "自分の名前など":
            instance.omoto = "your_name"
        form.save()
        return JsonResponse({"message": "works"})
    context = {'form': form}
    return render(request, "crop_images/main.html", context)

管理画面でのフィルタリング用に、スーパーユーザーかつ自分の名前に合致した場合に、目印ワードをつけておきます。19行目


templates/base.html

※雛形のイメージhtmlです 用途に合わせて手直してください


{% load static %}
<!doctype html>
<html lanh=ja>
    <head>
        <!-- Require meta tags -->
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0", shrink-to-fit=none>
        
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
        
        <!-- custom css & js -->
        <link rel="stylesheet" href="{% static 'style.css' %}">
        <script src="{% static 'main.js' %}" defer></script>
        
        
        <title>Spiner | {% block title %}{% endblock title %}</title>
    </head>
    <body>
        <div class="container mt-3">
            {% block content %}
            {% endblock content %}
        </div>
        <!-- Optional Javascript -->
        <!-- jQuery first, then Popper.js, then Bootstrap JS-->
        <script
        src="https://code.jquery.com/jquery-3.6.3.min.js"
        integrity="sha256-pvPw+upLPUjgMXY0G+8O0xUf+/Im1MZjXxxgOcBQBXU="
        crossorigin="anonymous"></script>
        <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
    </body>
</html>

templates/crop_images/main.html

base.htmlを継承したWEBサイト表示用のhtmlを作成します。


{% extends 'base.html' %}
{% load static %}
{% load widget_tweaks %}


{% block title %}Crop Image{% endblock title %}

{% block head_scripts %}
    <script src="{% static 'js/crop_image2.js' %}" defer></script>
{% endblock head_scripts %}

{% block contents %}
    <div class="row">
        <div class="col">
            <h3 class="mb-3">画像のトリミング</h3>

            <div id="alert-box"></div>
            <div id="image-box" class="mb-3"></div>
            <div id="preview-box" class="mb-3"></div>
            <div id="info-box" class="mb-3"></div>

            

            <form action="" id="image-form">
                {% csrf_token %}
                {% comment %} {{ form.as_p }} {% endcomment %}
                <label class="upload-label">
                    {% render_field form.file name="file" class="js-upload-file" %}画像ファイルを選択
                </label>
                <div class="js-upload-filename mb-3">ファイルが未選択です</div>

            </form>

            <div id="aspect" class="aspect not-visible">
                <span>アスペクト指定</span>
                <button class="btn-outline-primary btn mr-1 mb-1">16:9</button>
                <button class="btn-outline-primary btn mr-1 mb-1">4:3</button>
                <button class="btn-outline-primary btn mr-1 mb-1">1:1</button>
                <button class="btn-outline-primary btn mr-1 mb-1">2:3</button>
                <button class="btn-info btn mr-1 mb-1">Free</button>
            </div>

            <div id="ori_size" class="ori_size not-visible">
                <span>サイズ指定</span>
                <button class="btn-outline-primary btn mr-1 mb-1" type="butto">1000x666</button>
                <button class="btn-outline-primary btn mr-1 mb-1" type="butto">900x386</button>
                <button class="btn-outline-primary btn mr-1 mb-1" type="butto">539x363</button>
                <button class="btn-outline-primary btn mr-1 mb-1" type="butto">263x189</button>
                <button class="btn-outline-primary btn mr-1 mb-1" type="butto">263x363</button>
                <button class="btn-outline-primary btn mr-1 mb-1" type="butto">512x512</button>
                <p class="mt-2 text-danger text-center">サイズを指定し直す場合はFREEをクリック</p>
            </div>

            <div id="confirm-btn" class="not-visible">
                <button class="btn btn-primary mt-3">実行する</button>
                <button class="btn btn-danger mt-3" onclick="window.location.reload();">最初からはじめる</button>
            </div>

            <p id="png_dwn" class="text-center mt-5 not-visible"><a href="" id="download-link">出来上がった画像をPNGファイルでダウンロード</a></p>

        </div>
    </div>
    <hr>
    
{% endblock contents %}

{% block end_scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropper/4.1.0/cropper.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropper/4.1.0/cropper.css">

<script>
    $(function() {
      $('.js-upload-file').on('change', function () { //ファイルが選択されたら
        var file = $(this).prop('files')[0]; //ファイルの情報を代入(file.name=ファイル名/file.size=ファイルサイズ/file.type=ファイルタイプ)
        $('.js-upload-filename').text(file.name); //ファイル名を出力
      });
    });
    </script>
{% endblock end_scripts %}

Javascriptで制御させる以下のことを行っています。

  • メッセージを表示する領域を設ける。18行目
  • 画像を表示させる領域を設ける。19行目
  • トリミングした結果画像を表示させる領域を設ける。20行目
  • 画像が表示されたら加工用のボタンを表示。35~60行目
  • 既存のFileInputをカスタマイズ。69~76行目

既存のFileInputのカスタマイズには、以下のサイトを参考にさせていただきました。

参照:
https://techmemo.biz/css/input-file-name-clear/

static/js/crop_images2.js

console.log("hello world")

const alertBox = document.getElementById("alert-box")
const imageBox = document.getElementById("image-box")
const imageForm = document.getElementById("image-form")
// console.log(imageForm)
const confirmBtn = document.getElementById("confirm-btn")
const input = document.getElementById("id_file")

const previewBox = document.getElementById("preview-box")

const csrf = document.getElementsByName("csrfmiddlewaretoken")

const PngDwn = document.getElementById("png_dwn")
const InfoBox = document.getElementById("info-box")

const confirmAspect = document.getElementById("aspect")
const confirmSize = document.getElementById("ori_size")

var aspectRatio = document.querySelectorAll(".aspect .btn")
var OriSize = document.querySelectorAll(".ori_size .btn")

input.addEventListener("change", () => {
    
    console.log("changed")
    alertBox.innerHTML = ""
    previewBox.innerHTML = ""
    
    confirmBtn.classList.remove('not-visible')    
    confirmAspect.classList.remove('not-visible')    
    confirmSize.classList.remove('not-visible')    

    const img_data = input.files[0]
    const url = URL.createObjectURL(img_data)
    imageBox.innerHTML = `

Before

` var $image = $('#image'); $image.cropper({ dragMode : "move", viewMode: 2, // 元画像が縮小しない modal: false, // background: false, // 透過部分を表示しない scalable: false, zoomable: true, zoomRatio: 0.5, ready: function() { console.log("cropper ready") const imageData = this.cropper.getImageData(); const ratio = imageData.width / imageData.naturalWidth; console.log(ratio) // set aspect ratio aspectRatio[0].onclick = () => cropper.setAspectRatio(1.7777777777777777) // 16/9 aspectRatio[1].onclick = () => cropper.setAspectRatio(1.3333333333333333) aspectRatio[2].onclick = () => cropper.setAspectRatio(1) aspectRatio[3].onclick = () => cropper.setAspectRatio(0.6666666666666666) aspectRatio[4].onclick = () => cropper.setAspectRatio(0) // free // set specify size OriSize[0].onclick = () => cropper.setCropBoxData({ height: 666*ratio, width: 1000*ratio }) OriSize[1].onclick = () => cropper.setCropBoxData({ height: 386*ratio, width: 900*ratio }) OriSize[2].onclick = () => cropper.setCropBoxData({ height: 363*ratio, width: 539*ratio }) OriSize[3].onclick = () => cropper.setCropBoxData({ height: 189*ratio, width: 263*ratio }) OriSize[4].onclick = () => cropper.setCropBoxData({ height: 363*ratio, width: 263*ratio }) OriSize[5].onclick = () => cropper.setCropBoxData({ height: 512*ratio, width: 512*ratio }) }, crop: function (event) { console.log(event.detail.x); console.log(event.detail.y); console.log(event.detail.width); console.log(event.detail.height); console.log(event.detail.rotate); console.log(event.detail.scaleX); console.log(event.detail.scaleY); InfoBox.innerHTML = `

width:${Math.floor(event.detail.width)}px --- height:${Math.floor(event.detail.height)}px

` } }); // Get the Cropper.js instance after initialized var cropper = $image.data('cropper'); confirmBtn.addEventListener("click", () => { cropper.getCroppedCanvas().toBlob((blob) => { PngDwn.classList.remove("not-visible") console.log(blob) const croppedCanvas = cropper.getCroppedCanvas(); // Downloadの処理 png document.getElementById("download-link").addEventListener("click", ()=>{ const base64 = croppedCanvas.toDataURL("image/png") let a = document.createElement('a'); a.href = base64; a.download = "cropped.png"; a.click(); }) // 表示 webp previewBox.innerHTML = `

After

` const fd = new FormData() fd.append("csrfmiddlewaretoken", csrf[0].value) fd.append("file", blob, "my-image.webp") cropper.destroy() $.ajax({ type: "POST", url: imageForm.action, enctype: "multipart/form-data", data: fd, success: function(response) { console.log(response) alertBox.innerHTML = `` confirmAspect.classList.add('not-visible') confirmSize.classList.add('not-visible') }, error: function(error) { console.log(error) alertBox.innerHTML = `` }, cache: false, contentType: false, processData: false, }) }) }) })

一番悩んだところですが、ready項でアスペクトやサイズなどの変更に対処する指定のセットを記述できるようです。

Javascript側で、png形式でのダウンロードリンクを生成します。

cropper.jsについては、以下のサイトを参考にさせていただきました。

参照:
https://cly7796.net/blog/javascript/try-using-cropper-js/
https://www.memory-lovers.blog/entry/2021/04/22/110000
私の願い

私は神社の宮司です。神社や地域を担う次世代の人々に対し、何かを残してお役に立ててもらいたいとの願いが、強く芽生えました。個業としての神社や、小規模な地域社会に、恩恵が届くのが遅くなりそうな「デジタル」の分野。門外漢として奮闘した実体験から得た経験則を、わずかずつでも残し未来につなぎたいと願うばかりです。

More

若宮 住吉神社

サイトへ

最近の投稿

Copyright ©All rights reserved | by omo fun

Made withby Toshio Omote