다중 이미지 업로드 & 본문에 이미지 삽입

이번 편에서 완성되는 것

  • 다중 이미지 업로드(갤러리에서 여러 장 선택) → 서버 저장
  • 여러 이미지를 함께 Gemini에 전달하여 통합 아이디어 생성
  • 생성되는 글 초안에 이미지(Markdown) 삽입
  • 업로드된 이미지를 Flask에서 정적 제공하여 Flutter에서 표시

백엔드(Flask) 변경사항

1) 업로드된 이미지 정적 제공 라우트

# app.py (추가)
from flask import send_from_directory

@app.route('/uploads/<path:filename>')
def serve_upload(filename):
    return send_from_directory(app.config['UPLOAD_FOLDER'], filename)

이렇게 하면 http://<HOST>:5000/uploads/<파일명> 형태로 접근 가능.

2) 다중 이미지 업로드 API

# app.py (추가)
from werkzeug.utils import secure_filename
import os

@app.route('/api/upload-images', methods=['POST'])
def upload_images():
    # 클라이언트는 "images" 키로 여러 파일 전송
    files = request.files.getlist('images')
    if not files:
        return jsonify({"error": "No files provided"}), 400

    saved_paths = []
    saved_urls = []
    for f in files:
        if f and '.' in f.filename:
            ext = f.filename.rsplit('.', 1)[1].lower()
            if ext not in {'png', 'jpg', 'jpeg'}:
                continue
            filename = secure_filename(f.filename)
            filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
            f.save(filepath)
            saved_paths.append(filepath)  # 예: "uploads/xxx.jpg"
            # 요청 기반 절대 URL 생성
            base = request.host_url.rstrip('/')
            saved_urls.append(f"{base}/uploads/{filename}")

    if not saved_paths:
        return jsonify({"error": "No valid image files"}), 400

    return jsonify({
        "message": "Images uploaded",
        "file_paths": saved_paths,
        "file_urls": saved_urls
    }), 200

테스트 (curl):

# sample1.jpg, sample2.jpg가 프로젝트 폴더에 있어야 함
curl -X POST http://127.0.0.1:5000/api/upload-images \
  -F "images=@sample1.jpg" \
  -F "images=@sample2.jpg"

3) 여러 이미지를 Gemini에 전달해 아이디어 생성

# app.py (generate_ideas 수정 또는 신규 /api/generate-ideas-v2 추가 예시)
import google.generativeai as genai

def _guess_mime(path):
    ext = path.rsplit('.', 1)[-1].lower()
    return "image/png" if ext == "png" else "image/jpeg"

@app.route('/api/generate-ideas', methods=['POST'])
def generate_ideas():
    data = request.json or {}
    # 단일 or 다중 모두 지원
    paths = data.get("image_paths") or []
    if not paths:
        # 하위호환: image_path 단일 지원
        single = data.get("image_path")
        if single: paths = [single]

    # 유효성
    abs_paths = []
    for p in paths:
        if not os.path.exists(p):
            return jsonify({"error": f"Invalid image path: {p}"}), 400
        abs_paths.append(p)

    # 파일 읽기 → image parts 구성
    image_parts = []
    for p in abs_paths:
        with open(p, "rb") as f:
            image_parts.append({"mime_type": _guess_mime(p), "data": f.read()})

    # 프롬프트: 여러 이미지를 하나의 주제로 통합
    prompt = """
    다음의 여러 이미지를 함께 분석해줘.
    1) 공통적인 장면/주제/분위기를 요약 설명
    2) 핵심 키워드를 5~8개 추출
    3) 이 이미지들로 글을 쓴다면 적절한 주제를 5개 제시

    출력은 JSON 형식으로:
    {
      "description": "...",
      "keywords": ["...", "..."],
      "topics": ["...", "...", "..."]
    }
    """

    response = model.generate_content([prompt, *image_parts])

    return jsonify({"ideas": response.text}), 200

참고: Part 4와 동일하게 {"ideas": "<JSON 문자열>"} 형태로 반환합니다.

4) 글 초안에 이미지 삽입 (Markdown)

# app.py (/api/generate-text 수정)
@app.route('/api/generate-text', methods=['POST'])
def generate_text():
    data = request.json or {}
    topic = data.get("topic")
    category = data.get("category")
    keywords = data.get("keywords", [])
    image_paths = data.get("image_paths", [])  # 선택사항

    if not topic or not category:
        return jsonify({"error": "Topic and category are required"}), 400

    style_map = {
        "블로그 게시물": "친근하고 흥미로운 어조로 작성",
        "짧은 보고서": "객관적이고 사실적인 어조로 작성",
        "아이디어 노트": "간결하고 메모 형식으로 작성",
        "시": "문학적이고 감성적인 어조로 작성"
    }
    style_instruction = style_map.get(category, "자연스럽게 작성")

    # 이미지 URL 빌드 (클라이언트에서 표시하기 위한 Markdown)
    base = request.host_url.rstrip('/')
    image_urls = []
    for p in image_paths:
        fn = os.path.basename(p)
        image_urls.append(f"{base}/uploads/{fn}")

    # 모델 프롬프트 (본문 끝에 이미지 마크다운 첨부)
    prompt = f"""
    주제: "{topic}"
    카테고리: "{category}"
    키워드: {", ".join(keywords)}
    스타일: {style_instruction}

    약 150~200단어 내외의 글을 작성하되, 독자가 바로 게시할 수 있도록 자연스러운 문단 구성으로 출력하세요.
    가능하다면 이미지와 연결되는 묘사를 포함하세요.

    마지막에 아래 형식으로 이미지 마크다운을 나열하세요(있을 때만):
    {'\\n'.join([f'![image]({u})' for u in image_urls])}
    """

    response = model.generate_content(prompt)

    return jsonify({
        "topic": topic,
        "category": category,
        "generated_text": response.text,   # 마크다운 포함 가능
        "image_urls": image_urls
    }), 200

프론트엔드(Flutter) 변경사항

1) pubspec.yaml 업데이트

dependencies:
  flutter_markdown: ^0.6.18+3
  image_picker: ^1.1.2
  http: ^1.2.2
  provider: ^6.1.2
  # (Part 7 설정 그대로 + 필요 패키지 유지)

2) 다중 선택 & 업로드 API

// lib/services/api_service.dart (추가)
Future<Map<String, dynamic>> uploadImages(List<File> files) async {
  final uri = Uri.parse("${AppConfig.baseUrl}/api/upload-images");
  final req = http.MultipartRequest("POST", uri);
  for (final f in files) {
    req.files.add(await http.MultipartFile.fromPath("images", f.path));
  }
  final resp = await req.send();
  final body = await resp.stream.bytesToString();
  if (resp.statusCode == 200) {
    return jsonDecode(body) as Map<String, dynamic>;
  } else {
    throw Exception("uploadImages failed: $body");
  }
}

generateIdeasgenerateText배열 입력 지원:

// lib/services/api_service.dart (수정)
Future<Map<String, dynamic>> generateIdeasMulti(List<String> imagePaths) async {
  final uri = Uri.parse("${AppConfig.baseUrl}/api/generate-ideas");
  final resp = await http.post(
    uri,
    headers: {"Content-Type": "application/json"},
    body: jsonEncode({"image_paths": imagePaths}),
  );
  if (resp.statusCode == 200) {
    final outer = jsonDecode(resp.body);
    final ideasRaw = outer["ideas"];
    if (ideasRaw is String) return jsonDecode(ideasRaw);
    return ideasRaw;
  } else {
    throw Exception("generate-ideas failed: ${resp.body}");
  }
}

Future<String> generateTextWithImages({
  required String topic,
  required String category,
  List<String> keywords = const [],
  List<String> imagePaths = const [],
}) async {
  final uri = Uri.parse("${AppConfig.baseUrl}/api/generate-text}");
  final resp = await http.post(
    uri,
    headers: {"Content-Type": "application/json"},
    body: jsonEncode({
      "topic": topic,
      "category": category,
      "keywords": keywords,
      "image_paths": imagePaths,
    }),
  );
  if (resp.statusCode == 200) {
    final json = jsonDecode(resp.body);
    return (json["generated_text"] as String?)?.trim() ?? "";
  } else {
    throw Exception("generate-text failed: ${resp.body}");
  }
}

3) Provider: 다중 이미지 상태

// lib/providers/idea_provider.dart (핵심 변경)
class IdeaProvider extends ChangeNotifier {
  final ApiService api = ApiService();

  List<File> selectedImages = [];
  List<String> uploadedPaths = []; // e.g., ["uploads/a.jpg","uploads/b.jpg"]
  List<String> uploadedUrls = [];  // e.g., ["http://.../uploads/a.jpg", ...]
  String description = "";
  List<String> keywords = [];
  List<String> topics = [];
  String? selectedTopic;
  String category = "블로그 게시물";
  String generatedText = "";
  bool loading = false;
  String? error;

  Future<void> setImages(List<File> files) async {
    selectedImages = files;
    uploadedPaths = [];
    uploadedUrls = [];
    description = "";
    keywords = [];
    topics = [];
    selectedTopic = null;
    generatedText = "";
    error = null;
    notifyListeners();
  }

  Future<void> uploadAndFetchIdeasMulti() async {
    if (selectedImages.isEmpty) return;
    loading = true; error = null; notifyListeners();
    try {
      final res = await api.uploadImages(selectedImages);
      uploadedPaths = (res["file_paths"] as List).map((e) => "$e").toList().cast<String>();
      uploadedUrls  = (res["file_urls"]  as List).map((e) => "$e").toList().cast<String>();

      final ideaJson = await api.generateIdeasMulti(uploadedPaths);
      description = (ideaJson["description"] as String?) ?? "";
      keywords = (ideaJson["keywords"] as List?)?.map((e) => "$e").toList().cast<String>() ?? [];
      topics = (ideaJson["topics"] as List?)?.map((e) => "$e").toList().cast<String>() ?? [];
    } catch (e) {
      error = e.toString();
    } finally {
      loading = false; notifyListeners();
    }
  }

  Future<void> generateDraftWithImages() async {
    if (selectedTopic == null) {
      error = "주제를 선택해주세요.";
      notifyListeners();
      return;
    }
    loading = true; error = null; notifyListeners();
    try {
      generatedText = await api.generateTextWithImages(
        topic: selectedTopic!,
        category: category,
        keywords: keywords,
        imagePaths: uploadedPaths,
      );
    } catch (e) {
      error = e.toString();
    } finally {
      loading = false; notifyListeners();
    }
  }
}

4) Home: 여러 장 선택

// lib/screens/home_screen.dart (갤러리 다중 선택 버튼 추가)
import 'package:image_picker/image_picker.dart';
import 'dart:io';

Future<void> _pickMulti(BuildContext context) async {
  final picker = ImagePicker();
  final xs = await picker.pickMultiImage(imageQuality: 85);
  if (xs.isNotEmpty) {
    await context.read<IdeaProvider>().setImages(xs.map((x) => File(x.path)).toList());
    // 업로드 & 아이디어 생성은 다음 화면에서 수행
    Navigator.push(context, MaterialPageRoute(builder: (_) => const IdeaScreen()));
  }
}

// 버튼 예시
OutlinedButton.icon(
  icon: const Icon(Icons.collections_outlined),
  label: const Text("여러 장 선택"),
  onPressed: () => _pickMulti(context),
),

5) IdeaScreen: 썸네일 프리뷰 & 다중 업로드 호출

// lib/screens/idea_screen.dart (initState에서 uploadAndFetchIdeasMulti 호출)
@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    context.read<IdeaProvider>().uploadAndFetchIdeasMulti();
  });
}

// 썸네일 프리뷰(상단에 Grid)
final thumbs = p.uploadedUrls; // 업로드 이후 접근 가능 (업로드 전에는 selectedImages로 로컬 썸네일 표시)
if (thumbs.isNotEmpty) SizedBox(
  height: 110,
  child: ListView.separated(
    scrollDirection: Axis.horizontal,
    itemBuilder: (_, i) => ClipRRect(
      borderRadius: BorderRadius.circular(8),
      child: Image.network(thumbs[i], height: 100),
    ),
    separatorBuilder: (_, __) => const SizedBox(width: 8),
    itemCount: thumbs.length,
  ),
),

6) ResultScreen: Markdown 렌더링으로 이미지 포함

// lib/screens/result_screen.dart
import 'package:flutter_markdown/flutter_markdown.dart';

// 본문 표시 위젯 교체
Expanded(
  child: SingleChildScrollView(
    child: MarkdownBody(
      data: p.generatedText.isEmpty ? "생성된 내용이 없습니다." : p.generatedText,
      shrinkWrap: true,
    ),
  ),
),

체크리스트

  1. 여러 장 선택 → 썸네일 프리뷰 표시
  2. 업로드 후 /api/generate-ideas에서 통합 주제/키워드가 생성되는지
  3. /api/generate-text 응답에 이미지 Markdown이 포함되는지
  4. 결과 화면에서 이미지가 본문에 렌더링되는지
  5. (실기기) baseUrl과 정적 경로(/uploads/...)가 접근 가능한지

트러블슈팅

  • 이미지가 표시되지 않음 → 기기에서 PC의 http://<IP>:5000/uploads/...에 접근 가능한지 확인(동일 Wi-Fi, 방화벽 해제)
  • CORS/네트워크 오류 → 모바일 보안 정책/프록시 확인, 테스트는 에뮬레이터가 간편
  • 응답 포맷 에러ideas JSON 이중 파싱(문자열 → JSON) 로직 점검

TechTinkerer's에서 더 알아보기

구독을 신청하면 최신 게시물을 이메일로 받아볼 수 있습니다.

댓글 남기기

  • Understanding Pointers and Memory Management in C++

    [Tutorial] · 2026-04-30 05:10 UTC Understanding Pointers and Memory Management in C++ 💡 TL;DR Mastering pointers in C++ is crucial for efficient memory management and writing effective code. 📚 Learning Objectives This tutorial covers the fundamentals of pointers in C++, including declaration, initialization, and memory management. Students will learn how to effectively use pointers to…

  • Building a Command-Line Calculator with C++

    [Tutorial] · 2026-04-30 04:08 UTC Building a Command-Line Calculator with C++ 💡 TL;DR Learn how to build a command-line calculator in C++ that takes user input and performs basic arithmetic operations. 📚 Learning Objectives This tutorial guides you through creating a basic command-line calculator in C++. You’ll learn how to take user input, perform arithmetic…

  • Mastering Python Data Structures for Efficient Coding

    [Tutorial] · 2026-04-30 03:05 UTC Mastering Python Data Structures for Efficient Coding 💡 TL;DR Learn about Python’s fundamental data structures – arrays, lists, tuples, and dictionaries – to write efficient and scalable code. 📚 Learning Objectives This tutorial covers the essential Python data structures – arrays, lists, tuples, and dictionaries. You’ll learn about their usage,…

  • Introduction to Object-Oriented Programming in Python

    [Tutorial] · 2026-04-30 02:02 UTC Introduction to Object-Oriented Programming in Python 💡 TL;DR Learn the fundamentals of object-oriented programming in Python, including classes and objects, inheritance, and polymorphism. 📚 Learning Objectives This tutorial introduces the basics of object-oriented programming in Python, covering classes, objects, inheritance, and polymorphism. By the end of this tutorial, beginners will…

  • Complete Guide to Python List Comprehensions

    [Tutorial] · 2026-04-30 01:00 UTC Complete Guide to Python List Comprehensions 💡 TL;DR Master Python list comprehensions to write concise and efficient code for data manipulation and transformation tasks. 📚 Learning Objectives This tutorial covers the basics of Python list comprehensions, including syntax, use cases, and execution results. You’ll learn how to write efficient and…

← 뒤로

응답해 주셔서 감사합니다. ✨

TechTinkerer's에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기

TechTinkerer's에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기