Skip to content

Latest commit

 

History

53 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pinger

초수위화

기술스택

Flutter(Client)
   → Base64 Encoded Sketch
      → Flask API (/create)
          → ControlNet (Colab / API(generate))
              → Generated Image
                  → Firebase Storage Upload
                      → Firestore Metadata 저장
                          → Flutter 리스트뷰에서 표시  

구성 요소 기술 스택 설명
Client (App) Flutter 사용자 UI 및 앱 동작
Server Python + Flask (로컬 서버) 이미지 저장, 메타데이터 저장, API 제공
AI 모델 서버 Google Colab + Hugging Face 스케치 기반 이미지 생성 (ControlNet)
DB Firebase Firestore 이미지 메타데이터 관리
이미지 저장소 로컬 디렉토리 (/images), Google Drive 생성 이미지 저장소

image 1

image 2

image 2

ex 그리기 1

ex 그리기 2

ex 그리기 3

DrawingCanvas
class DrawingCanvas extends StatelessWidget {
  final GlobalKey repaintKey;
  final bool isDrawingEnabled;

  const DrawingCanvas({
    super.key,
    required this.repaintKey,
    this.isDrawingEnabled = true,
  });

  @override
  Widget build(BuildContext context) {
    final manager = context.watch<DrawingManager>();

    return RepaintBoundary(
      key: repaintKey,
      child: GestureDetector(
        onPanStart: (details) {
          RenderBox box = context.findRenderObject() as RenderBox;
          Offset point = box.globalToLocal(details.globalPosition);
          isDrawingEnabled ? manager.startSketch(point) : null;
        },
        onPanUpdate: (details) {
          RenderBox box = context.findRenderObject() as RenderBox;
          Offset point = box.globalToLocal(details.globalPosition);
          isDrawingEnabled ? manager.addPoint(point) : null;
        },
        onPanEnd: (_) => isDrawingEnabled ? manager.endSketch() : null,
        child: Container(
          color: Colors.white,
          child: CustomPaint(
            painter: DrawingPainter(
              manager.sketches,
              currentPoint: manager.currentDrawingPoint,
            ),
            size: Size.infinite,
          ),
        ),
      ),
    );
  } // build
} // DrawingCanvas
DrawingPainter
class DrawingPainter extends CustomPainter {
  final List<Sketch> sketches;
  final Offset? currentPoint;

  DrawingPainter(this.sketches, {this.currentPoint});

  // Methods
  // ...

  @override
  void paint(Canvas canvas, Size size) {
    canvas.saveLayer(Offset.zero & size, Paint());

    for (final sketch in sketches) {
      canvas.drawPath(sketch.path, sketch.paint);
    }

    if (currentPoint != null) {
      final glowPaint = Paint()
        ..color = Colors.black.withValues(alpha: 0.2)
        ..style = PaintingStyle.fill
        ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);

      canvas.drawCircle(currentPoint!, 16, glowPaint);
    }

    canvas.restore();
  } // paint

  @override
  bool shouldRepaint(covariant DrawingPainter oldDelegate) {
    return oldDelegate.sketches != sketches ||
        oldDelegate.currentPoint != currentPoint;
  } // shouldRepaint
} // DrawingPainter

Edit

image 1

image 1

image 2

프롬프트 입력 1

프롬프트 입력 2

펜 굵기

image 1

image 2

image 2

image 2

지우개

전체삭제

PathHistory
class PathHistory {
  final List<Sketch> _sketches = [];
  final List<Sketch> _undone = [];

  List<Sketch> get sketches => List.unmodifiable(_sketches);

  // 새로운 스케치 추가
  void add(Sketch sketch) {
    _sketches.add(sketch);
    _undone.clear();
  }

  // 전체 삭제
  void clear() {
    _sketches.clear();
    _undone.clear();
  }

  // 실행 취소 (undo)
  void undo() {
    if (_sketches.isNotEmpty) {
      _undone.add(_sketches.removeLast());
    }
  }

  // 다시 실행 (redo)
  void redo() {
    if (_undone.isNotEmpty) {
      _sketches.add(_undone.removeLast());
    }
  }
} // PathHistory
DrawingManager
class DrawingManager extends ChangeNotifier {
  final PathHistory _history = PathHistory();
  Sketch? _currentSketch;
  bool _eraseMode = false;
  double _strokeWidth = 4.0;

  List<Sketch> get sketches {
    if (_currentSketch == null) return _history.sketches;
    return [..._history.sketches, _currentSketch!];
  } // sketches

  Offset? get currentDrawingPoint {
    final metrics = _currentSketch?.path.computeMetrics().toList();
    if (metrics == null || metrics.isEmpty) return null;

    final lastMetric = metrics.last;
    final lastTangent = lastMetric.getTangentForOffset(lastMetric.length);
    return lastTangent?.position;
  } // currentDrawingPoint

  set eraseMode(bool val) {
    _eraseMode = val;
  } // eraseMode

  set strokeWidth(double val) {
    _strokeWidth = val;
  } // strokeWidth

  // Methods
  // ....

  void startSketch(Offset point, {Color color = Colors.black}) {
    final paint = Paint()
      ..color = _eraseMode ? const Color(0x00000000) : color
      ..blendMode = _eraseMode ? BlendMode.clear : BlendMode.srcOver
      ..strokeWidth = _eraseMode ? _strokeWidth * 5.0 : _strokeWidth
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke;

    final path = Path()..moveTo(point.dx, point.dy);
    _currentSketch = Sketch(path: path, paint: paint);
  } // startSketch

  void addPoint(Offset point) {
    _currentSketch?.path.lineTo(point.dx, point.dy);
    notifyListeners();
  } // addPoint

  void endSketch() {
    if (_currentSketch != null) {
      _history.add(_currentSketch!);
      _currentSketch = null;
      notifyListeners();
    }
  } // endSketch

  void clear() {
    _history.clear();
    _currentSketch = null;
  } // clear

  void undo() {
    _history.undo();
  } // undo

  void redo() {
    _history.redo();
  } // undo
} // DrawingManager

Generate

image 1

image 2

image 2

결과 반환 및 저장 1

결과 반환 및 저장 2

list

AI Code
class ImageAI:
    def __init__(self):
        self.hed = HEDdetector.from_pretrained('lllyasviel/Annotators')

        # ControlNet Scribble 모델 로드
        self.controlnet = ControlNetModel.from_pretrained(
            "lllyasviel/sd-controlnet-scribble",
            torch_dtype=torch.float16
        )

        # Stable Diffusion 파이프라인 설정
        self.pipe = StableDiffusionControlNetPipeline.from_pretrained(
            "runwayml/stable-diffusion-v1-5",
            controlnet=self.controlnet,
            safety_checker=None,
            torch_dtype=torch.float16
        )

        self.pipe.scheduler = UniPCMultistepScheduler.from_config(self.pipe.scheduler.config)
        self.pipe.enable_model_cpu_offload()
    ## __init__(self)

    def generate_from_sketch(self, b64_string: str, prompt: str) -> str:
        init_image = self.__base64_to_pil(b64_string)

        detected_scribble = self.hed(init_image) # 선 감지

        output_image = self.pipe(
            prompt=prompt,
            image=detected_scribble,
            guidance_scale=7.5,
            num_inference_steps=30
        ).images[0]

        self.__save_to_drive(output_image, prompt)

        return self.__pil_to_base64(output_image)
    ## generate_from_sketch(self, b64_string: str, prompt: str)

    @staticmethod
    def __save_to_drive(img: Image.Image, prompt: str):
        save_dir = "/content/drive/MyDrive/"
        os.makedirs(save_dir, exist_ok=True)

        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{timestamp}.png"
        save_path = os.path.join(save_dir, filename)
        img.save(save_path)
        print(f"이미지가 Google Drive에 저장되었습니다: {save_path}")
    ## __save_to_drive(img: Image.Image, prompt: str)

    @staticmethod
    def __base64_to_pil(b64_string: str) -> Image.Image:
        img_bytes = base64.b64decode(b64_string)
        img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
        return img
    ## __base64_to_pil(b64_string: str)

    @staticmethod
    def __pil_to_base64(img: Image.Image) -> str:
        buffered = io.BytesIO()
        img.save(buffered, format="PNG")
        return base64.b64encode(buffered.getvalue()).decode()
    ## __pil_to_base64(img: Image.Image)
## class ImageAI
@app.route("/generate", methods=["POST"])
def generate():
    data = request.json
    base64_image = data.get("image")
    prompt = data.get("prompt")

    if not base64_image or not prompt:
        return jsonify({"error": "Missing image or prompt"}), 400

    try:
        result_base64 = image_ai.generate_from_sketch(base64_image, prompt)
        return jsonify({"image": result_base64})
    except Exception as e:
        return jsonify({"error": str(e)}), 500
## generate
generate (REST API)
@app.route("/generate", methods=["POST"])
def generate():
    data = request.json
    base64_image = data.get("image")
    prompt = data.get("prompt")

    if not base64_image or not prompt:
        return jsonify({"error": "Missing image or prompt"}), 400

    try:
        result_base64 = image_ai.generate_from_sketch(base64_image, prompt)
        return jsonify({"image": result_base64})
    except Exception as e:
        return jsonify({"error": str(e)}), 500
## generate
CRUD
@app.route("/create", methods=["POST"])
def create():
    print(">>> /create 엔드포인트에 POST 요청 수신됨!")
    if not db:
        return jsonify({"error": "Firebase 초기화 실패"}), 500

    image = request.files.get("image")
    prompt = request.form.get("prompt")
    filename = request.form.get("filename")

    if not image or not prompt or not filename:
        return jsonify({"error": "Missing data"}), 400

    try:
        base_name = filename
        extension = ".png"
        full_name = base_name + extension
        image_path = os.path.join(UPLOAD_FOLDER, full_name)

        count = 1
        while os.path.exists(image_path):
            full_name = f"{base_name}_{count}{extension}"
            image_path = os.path.join(UPLOAD_FOLDER, full_name)
            count += 1

        # 이미지 저장
        image.save(image_path)

        image_url = f"images/{full_name}"

        doc_ref = db.collection("generated_images").document()
        doc_ref.set({
            "doc_id": doc_ref.id,
            "prompt": prompt,
            "filename": full_name,
            "image_url": image_url,
            "timestamp": datetime.now().isoformat()
        })

        return jsonify({
            "message": "Saved successfully",
            "doc_id": doc_ref.id,
            "image_url": image_url
        }), 201

    except Exception as e:
        print(f"Error in create function: {e}")
        return jsonify({"error": str(e)}), 500
## create
@app.route("/read", methods=["GET"])
def read():
    print(">>> /read 엔드포인트에 GET 요청 수신됨!")

    if not db:
        return jsonify({"error": "Firebase is not initialized."}), 500

    try:
        limit = int(request.args.get("limit", 10))
        docs = db.collection("generated_images") \
                 .order_by("timestamp", direction=firestore.Query.DESCENDING) \
                 .limit(limit) \
                 .stream()

        base_url = request.host_url.rstrip('/')
        result = []

        for doc in docs:
            data = doc.to_dict()
            data["id"] = doc.id

            data["image_url"] = f"images/{data['filename']}"
            result.append(data)

        return jsonify(result), 200
    except Exception as e:
        print(f"Error in read function: {e}")
        return jsonify({"error": str(e)}), 500
## read
@app.route("/delete/<doc_id>", methods=["DELETE"])
def delete(doc_id):
    print(f">>> /delete/{doc_id} DELETE 요청 수신됨!")

    if not db:
        return jsonify({"error": "Firebase is not initialized."}), 500

    try:
        doc_ref = db.collection("generated_images").document(doc_id)
        doc = doc_ref.get()

        if not doc.exists:
            return jsonify({"message": "No document found with that doc_id"}), 404

        data = doc.to_dict()
        filename = data.get("filename")
        image_path = os.path.join(UPLOAD_FOLDER, filename)

        # 이미지 삭제
        if os.path.exists(image_path):
            os.remove(image_path)
            print(f"로컬 파일 삭제됨: {image_path}")
        else:
            print(f"파일 없음: {image_path}")

        # Firestore 삭제
        doc_ref.delete()

        return jsonify({"message": "Document and image deleted successfully"}), 200
    except Exception as e:
        print(f"Error in delete function: {e}")
        return jsonify({"error": str(e)}), 500
## delete
@app.route("/update/<doc_id>", methods=["PUT"])
def update(doc_id):
    print(f">>> /update/{doc_id} PUT 요청 수신됨!")
    if not db:
        return jsonify({"error": "Firebase is not initialized."}), 500

    try:
        doc_ref = db.collection("generated_images").document(doc_id)
        doc = doc_ref.get()

        if not doc.exists:
            return jsonify({"message": "No document found with that doc_id"}), 404

        data = doc.to_dict()

        new_filename = request.form.get("filename")
        if not new_filename:
            return jsonify({"error": "filename 필드가 필요합니다."}), 400

        # 기존 파일 경로
        old_filename = data["filename"]
        image_path_old = os.path.join(UPLOAD_FOLDER, old_filename)

        # 새 파일 경로
        new_filename_full = new_filename + ".png"
        image_path_new = os.path.join(UPLOAD_FOLDER, new_filename_full)

        # 로컬 파일 이름 변경
        if os.path.exists(image_path_old):
            os.rename(image_path_old, image_path_new)
            print(f"파일명 변경 완료: {old_filename} -> {new_filename_full}")
        else:
            print(f"기존 이미지 없음: {image_path_old}")
            return jsonify({"error": "로컬 이미지 파일이 존재하지 않습니다."}), 404

        # Firestore 필드 업데이트
        doc_ref.update({
            "filename": new_filename_full,
            "image_url": f"images/{new_filename_full}"
        })

        return jsonify({"message": "Filename updated successfully"}), 200

    except Exception as e:
        print(f"Error in update function: {e}")
        return jsonify({"error": str(e)}), 500
## update
Maintenance Script

firebase(NoSQL) DB 수정, 데이터 정리, 백업 등 코드

참고

About

Full Stack - 드로잉 기반 이미지 생성

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages