Style Transfer Model 서비스 해보기 - FastAPI로 화면 구성

김철기·2022년 3월 4일
3

StyleTransfer

목록 보기
1/4

안녕하세요. Gameeye에서 deeplol.gg 서비스를 개발 중인 김철기입니다.
클라우드 서버 인프라 구축, 백엔드 개발, 딥러닝 모델 연구를 담당하고 있습니다.

해당 포스팅은 Style Transfer deeplearning 모델을 웹으로 서비스해보는 시리즈의 1편입니다. 1편에서는 FastAPI와 Jinja2를 활용하여 메인 페이지와 이미지 업로드 기능을 구현합니다. 필자는 AI 연구원으로 프론트엔드 역량이 부족하여 시리즈에서 백엔드와 모델 서빙에 집중함을 미리 알립니다.

시리즈

  • FastAPI와 Jinja2를 활용하여 메인 페이지 작성하기
  • Style Transfer Model의 결과를 반환하는 API 작성하기
  • AWS Beanstalk와 Docker, Github Action을 이용해 CI/CD 구축하기

들어가기에 앞서..

뭘 만들었는지, 뭘 참고해서 만들었는지, 뭘로 만들었는지 적도록 하겠습니다.

최종 결과

서비스 사용해보기 (서버비용 이슈로 사용종료)
Github 코드 보기

참고 자료

FastAPI Web Starter
이미지 업로드하기
Style Transfer Colab

기술 스택

  • python 3.8
  • FastAPI
  • Jinja2
  • Tensorflow==2.7.0(+Tensorflow-hub)
  • Docker
  • AWS Beanstalk
  • Javascript
  • HTML, CSS
  • Github(+Action)

실행 하기

  • git 코드 복사
git clone https://github.com/kimcheolgi/Ironkey-Project-Style-Transfer.git
  • 라이브러리 설치
pip install -r requirements.txt
  • 앱 실행
uvicorn app.main:app --reload --host=0.0.0.0 --port=8080

프로젝트 구조


기본 프로젝트 구조입니다. 다른 프로젝트에서 사용하던 구조를 그대로 가져왔는데 너무 복잡하다고 걱정하실 필요없습니다. 사용하는 부분만 설명하겠습니다. (설명하지 않은 부분은 다른 프로젝트에서 사용할 때 설명하겠습니다.)

  • .github/workflows: Github action을 동작하기 위한 폴더입니다.
  • app/library: 비즈니스 로직을 관리합니다.
  • app/pages: 간단한 마크다운을 관리합니다.
  • app/routers: API 라우터를 관리합니다.
  • app/main.py: 앱 세팅과 실행을 합니다.
  • static: 정적인 파일들을 관리합니다.
  • templates: HTML 템플릿을 관리합니다.
  • Dockerfile: Docker 플랫폼에서 실행될 이미지를 정의합니다.
  • requirements: 라이브러리 정보를 가지고 있습니다.

해당 포스팅에서는 굵게 표시된 부분만 설명하고 나머지는 다음 포스팅에서 설명하겠습니다.

메인 페이지 템플릿 구현

메인 페이지는 기본적으로 페이지 좌측에 표현되는 sidebar, 페이지 상단에 표현되는 topnav, 페이지 중앙에 표현되는 contents로 구성됩니다. jinja2를 사용하면 3가지 구성을 각각의 html로 구현한 뒤 하나의 페이지로 합칠 수 있습니다.

templates/include/sidebar.html

화면 좌측에 표시되는 sidebar입니다.

<!-- Sidebar  -->
<nav id="sidebar">
    <div id="dismiss">
        <i class="fas fa-arrow-left"></i>
    </div>

    <div class="sidebar-header">
        <h4>Ironkey Space</h4>
    </div>

    <ul class="list-unstyled components">
        <li>
            <a href="/">Home</a>
        </li>
    </ul>
</nav>

templates/include/topnav.html

화면 상단에 표시되는 topnav입니다.

<nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container-fluid">

        <button type="button" id="sidebarCollapse" class="btn btn-new">
            <i class="fas fa-align-left"></i>
            <span>Menu</span>
        </button>
        <button class="btn btn-dark d-inline-block d-lg-none ml-auto" type="button" data-toggle="collapse"
            data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false"
            aria-label="Toggle navigation">
            <i class="fas fa-align-justify"></i>
        </button>

        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="nav navbar-nav ml-auto">

            </ul>
        </div>
    </div>
</nav>

templates/base.html

기본적인 화면 구성을 정의하는 코드입니다.

눈여겨 보실 부분은 {% block 어쩌구 %} {% endblock %}으로 표시되어 있는 부분과 {% include 어쩌구 %}로 표시되어 있는 부분입니다. {% block 어쩌구 %} {% endblock %} 부분은 향후 base.html을 상속받는 코드에서 정의된 내용이 채워집니다. {% include 어쩌구 %} 부분은 미리 정의된 코드를 가져와 사용합니다.
{% include 'include/sidebar.html' %} 부분은 앞서 정의한 sidebar 코드를 가져와 사용합니다.

<!DOCTYPE html>
<html lang="en">

<head>
    {% block head %}
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="description" content="Ironkey Project 1">
    <meta name="author" content="Ironkey">

    <title>{% block title %}{% endblock %}</title>
    <link rel="icon" href="{{ url_for('static', path='/images/favicon.png') }}" sizes="32x32" />

    <!-- Bootstrap CSS CDN -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css"
        integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
    <!-- Custom CSS -->
    <link href="{{ url_for('static', path='/css/style3.css') }}" rel="stylesheet">
    <link href="{{ url_for('static', path='/css/mystyle.css') }}" rel="stylesheet">

    <!-- Scrollbar Custom CSS -->
    <link rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/malihu-custom-scrollbar-plugin/3.1.5/jquery.mCustomScrollbar.min.css">


    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
    <!-- Font Awesome JS -->
    <script src="https://kit.fontawesome.com/543c4560e5.js" crossorigin="anonymous"></script>
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
            integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
            crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
            integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
            crossorigin="anonymous"></script>
    <link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.js"></script>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    {% endblock %}
</head>

<body>
    <div class="wrapper">
        {% include 'include/sidebar.html' %}
        <!-- Page Content  -->
        <div id="content">
            {% include 'include/topnav.html' %}
            {% block page_content %}
            {% endblock %}
        </div>
    </div>
    <div class="overlay"></div>


    {% block scripts %}

    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <!-- Popper.JS -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.0/umd/popper.min.js"
        integrity="sha384-cs/chFZiN24E4KMATLdqdvsezGxaGsi4hLGOzlXwp5UZB1LY//20VyM2taTB4QvJ"
        crossorigin="anonymous"></script>
    <!-- Bootstrap JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx"
        crossorigin="anonymous"></script>
    <!-- jQuery Custom Scroller CDN -->
    <script
        src="https://cdnjs.cloudflare.com/ajax/libs/malihu-custom-scrollbar-plugin/3.1.5/jquery.mCustomScrollbar.concat.min.js"></script>
    <script type="text/javascript">
        $(document).ready(function () {
            $("#sidebar").mCustomScrollbar({
                theme: "minimal"
            });

            $('#dismiss, .overlay').on('click', function () {
                $('#sidebar').removeClass('active');
                $('.overlay').removeClass('active');
            });

            $('#sidebarCollapse').on('click', function () {
                $('#sidebar').addClass('active');
                $('.overlay').addClass('active');
                $('.collapse.in').toggleClass('in');
                $('a[aria-expanded=true]').attr('aria-expanded', 'false');
            });
        });
    </script>
    {% endblock %}
</body>

</html>

app/pages/home.md

메인 페이지 소개글이 작성된 마크다운입니다.

<div id="top">
<h1>Welcome to Ironkey Project 1 - Style Transfer</h1>
<p></p>
<p>
This is the Ironkey project space.
</p>
<p>
It is a service that synthesizes images and styles with the Style Transfer model. (Only images smaller than 1MB can be uploaded.)
</p>
</div>

templates/page.html

컨텐츠가 포함된 페이지 중앙 부분을 구성하는 코드입니다.

여기서 눈여겨 보실 부분은 {% extends "base.html" %}{{ super() }}, {% block page_content %} {% endblock %} 입니다. extends는 앞서 작성한 템플릿을 상속받는 명령어입니다. 또 super는 상속받은 템플릿의 요소를 가져와 사용할 수 있게 해줍니다.
block 명령어를 사용하여 base.html에서 작성한 page_content block에 해당 페이지 코드를 채워줍니다.
앞서 작성한 마크다운은 {{data.text|safe}} 형식으로 적용합니다.
*참고: jinja2에서는 명령어가 포함된 구문은 {% %} 형식을 데이터 구문은 {{ }}형식으로 작성합니다.

{% extends "base.html" %}

{% block title %}Ironkey Project Style Transfer{% endblock %}
{% block head %}
{{ super() }}

{% endblock %}

{% block page_content %}


<main role="main" class="container overflow-hidden">
    <div class="row">
        {{data.text|safe}}
    </div>
    <div class="row">

        <div class="col-md-4 col-0 text-center">
            <div class="col-12 up-area">
                <h1>Origin Image</h1>
                <input type="file" name="file" id="file">
                <!-- Drag and Drop container-->
                <div class="upload-area" id="uploadfile">
                    <h2 id="howto">Drag and Drop file here<br />Or<br />Click to Upload</h2>
                    <button type="submit" class="box_button">Upload</button>
                </div>
            </div>

            <div class="col-12">
                <div class="colorpalette">
                    <h3>Uploaded Image</h3>
                    <div id="result" class="row">
                        <div id="original" class="col-12">
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

</main><!-- /.container -->


{% endblock %}

{% block scripts %}
{{ super() }}

<script type="module" src="{{ url_for('static', path='/js/dragdrop.js') }}"></script>

{% endblock %}

페이지 인터랙션

해당 프로젝트에서는 CSS와 JS를 이용해 페이지를 꾸미고 드래그&드랍, 온클릭 이벤트 인터랙션을 구현합니다. 앞서 말씀드린 이유로 프론트엔드 관련된 내용은 참고 자료를 공유하는 것으로 줄이겠습니다.
이미지 업로드하기

메인 페이지 API

메인 페이지 엔드포인드를 정의하고 렌더링될 페이지와 데이터를 정의해줍니다.

router = APIRouter()
templates = Jinja2Templates(directory="templates/")


@router.get("/", response_class=HTMLResponse)
async def home(request: Request):
    data = openfile("home.md")
    return templates.TemplateResponse("page.html", {"request": request, "data": data})

앱 정의 및 실행

미리 정의된 미들웨어와 라우터로 앱을 생성 후 uvicorn으로 앱을 실행합니다.

def create_app():
    """
    앱 함수 실행
    :return:
    """

    app = FastAPI()
    app.mount("/static", StaticFiles(directory="static"), name="static")


    # 미들웨어 정의
    app.add_middleware(middleware_class=BaseHTTPMiddleware, dispatch=access_control)
    app.add_middleware(
        CORSMiddleware,
        allow_origins=conf().ALLOW_SITE,
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    app.add_middleware(TrustedHostMiddleware, allowed_hosts=conf().TRUSTED_HOSTS, except_path=["/health"])

    # 라우터 정의
    app.include_router(index.router)
    return app

app = create_app()

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)

여기까지 작업하신 후 실행시켜보시면 아래와 같은 페이지가 완성됩니다.

이미지 업로드 기능

받은 원본 이미지를 저장하고 원본 이미지를 썸네일로 변환하여 저장합니다.

app/library/helpers.py

def set_dimensions(winWidth, imgWidth, imgHeight):
    ratio = winWidth / imgWidth
    new_imgWidth = int(imgWidth * ratio * 0.8)
    max_imgWidth = settings.max_imgWidth
    max_imgHeight = settings.max_imgHeight
    if (new_imgWidth > max_imgWidth):
        new_imgWidth = max_imgWidth

    return (new_imgWidth, max_imgHeight)


def create_workspace():
    """
    Return workspace path
    """
    # base directory
    work_dir = Path(settings.work_dir)
    # UUID to prevent file overwrite
    request_id = Path(str(uuid.uuid4())[:8])
    # path concat instead of work_dir + '/' + request_id
    workspace = work_dir / request_id
    if not os.path.exists(workspace):
        # recursively create workdir/unique_id
        os.makedirs(workspace)

    return workspace


def thumb(myfile, winWidth, imgWidth, imgHeight):
    size = set_dimensions(winWidth, imgWidth, imgHeight)
    # size = settings.thumb_width, settings.thumb_height
    filepath, ext = os.path.splitext(myfile)
    # print(ext)
    im = Image.open(myfile)
    im = image_transpose_exif(im)
    im.thumbnail(size)
    imgtype = "PNG" if ext == ".png" else "JPEG"
    # print(imgtype)
    im.save(filepath + ".thumbnail"+ext, imgtype)


def image_transpose_exif(im):
    """
    https://stackoverflow.com/questions/4228530/pil-thumbnail-is-rotating-my-image
    Apply Image.transpose to ensure 0th row of pixels is at the visual
    top of the image, and 0th column is the visual left-hand side.
    Return the original image if unable to determine the orientation.
    As per CIPA DC-008-2012, the orientation field contains an integer,
    1 through 8. Other values are reserved.
    Parameters
    ----------
    im: PIL.Image
       The image to be rotated.
    """

    exif_orientation_tag = 0x0112
    exif_transpose_sequences = [   # Val 0th row  0th col
        [],  # 0 (reserved)
        [],  # 1 top left
        [Image.FLIP_LEFT_RIGHT],  # 2 top right
        [Image.ROTATE_180],  # 3 bottom right
        [Image.FLIP_TOP_BOTTOM],  # 4 bottom left
        [Image.FLIP_LEFT_RIGHT, Image.ROTATE_90],  # 5 left top
        [Image.ROTATE_270],  # 6 right top
        [Image.FLIP_TOP_BOTTOM, Image.ROTATE_90],  # 7 right bottom
        [Image.ROTATE_90],  # 8 left bottom
    ]

    try:
        seq = exif_transpose_sequences[im._getexif()[exif_orientation_tag]]
    except Exception:
        return im
    else:
        return functools.reduce(type(im).transpose, seq, im)

이미지 업로드 API

POST로 동작하는 API입니다.

반환값을 pydantic으로 검증하기 위해 UploadedData를 작성했습니다. response_model에 작성한 모델을 넣어주면 모델의 구조와 반환값의 구조가 동일한지 검증해줄 수 있습니다. 이후 앞서 작성한 비즈니스 로직을 이용해 이미지를 저장합니다.

from pydantic.main import BaseModel

class UploadedData(BaseModel):
    img_path: str
    thumb_path: str

@router.post("/upload/new/", response_model=UploadedData)
async def post_upload(imgdata: tuple, file: UploadFile = File(...)):
    data_dict = eval(imgdata[0])
    winWidth, imgWidth, imgHeight = data_dict["winWidth"], data_dict["imgWidth"], data_dict["imgHeight"]

    # create the full path
    workspace = create_workspace()
    # filename
    file_path = Path(file.filename)
    # image full path
    img_full_path = workspace / file_path
    with open(str(img_full_path), 'wb') as myfile:
        contents = await file.read()
        myfile.write(contents)
    # create a thumb image and save it
    thumb(img_full_path, winWidth, imgWidth, imgHeight)
    # create the thumb path
    # ext is like .png or .jpg
    filepath, ext = os.path.splitext(img_full_path)
    thumb_path = filepath + ".thumbnail"+ext
    
    return UploadedData(
        img_path=str(img_full_path),
        thumb_path=str(thumb_path)
    )

작성된 코드를 실행시키면 아래와 같은 결과물을 얻을 수 있습니다. 업로드된 이미지가 썸네일로 변환되어 페이지에 표현되는 것이 확인됩니다. Style Image와 Transfer Image도 Origin Image 템플릿과 동일하게 작성되었습니다.

정리

해당 포스팅에서는 기본적인 FastAPI의 기능과 Jinja2의 기능을 이용하여 Style Transfer 서비스의 메인 페이지를 만들어 보았습니다. 이후 포스팅에서는 Tensorflow Hub를 이용하여 원본 이미지와 스타일 이미지가 합성된 결과물을 페이지에 표시하는 작업을 진행해보겠습니다. 포스팅에서 이해가 안되는 부분이 있으시면 댓글에 남겨주시면 답변드리겠습니다. 감사합니다.

profile
Deepveloper, deeplol.gg, fleaman.shop

0개의 댓글