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 |
생성 이미지 저장소 |
|
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
} // DrawingCanvasDrawingPainter
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
|
프롬프트 입력 1 |
프롬프트 입력 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());
}
}
} // PathHistoryDrawingManager
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
|
결과 반환 및 저장 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
## generategenerate (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
## generateCRUD
@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
## updateMaintenance Script
firebase(NoSQL) DB 수정, 데이터 정리, 백업 등 코드













