Spaces:
Running
Running
import streamlit as st | |
import cv2 | |
import numpy as np | |
import tempfile | |
import os | |
from pathlib import Path | |
from typing import Optional, Tuple | |
from moviepy.editor import VideoFileClip | |
import torch | |
from PIL import Image | |
# ============================== | |
# Streamlit page config & Custom CSS | |
# ============================== | |
st.set_page_config( | |
page_title="Ansim Blur - Face Privacy Protection", | |
page_icon="๐", | |
layout="wide", | |
initial_sidebar_state="expanded" | |
) | |
# Custom CSS - ๋ ์ด์์ ์์ ํ๋ฅผ ์ํ ์์ | |
st.markdown(""" | |
<style> | |
/* ์ปจํ ์ด๋ ๊ณ ์ ๋์ด ์ค์ ์ผ๋ก ํ๋ค๋ฆผ ๋ฐฉ์ง */ | |
.image-container { | |
min-height: 400px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
/* ๋ฉ์ธ ์ปจํ ์ด๋ ์์ ํ */ | |
.main .block-container { | |
max-width: 1400px; | |
padding-top: 2rem; | |
padding-bottom: 2rem; | |
} | |
/* ์ปฌ๋ผ ๊ณ ์ */ | |
[data-testid="column"] { | |
min-height: 500px; | |
} | |
/* ์ด๋ฏธ์ง ์ ๋ก๋ ์์ญ ๊ณ ์ */ | |
[data-testid="stFileUploader"] { | |
min-height: 150px; | |
} | |
/* ๋ฒํผ ์์ญ ๊ณ ์ */ | |
.stButton { | |
min-height: 60px; | |
} | |
/* ํ๋ก๊ทธ๋ ์ค ๋ฐ ์์ญ ๊ณ ์ */ | |
.stProgress { | |
min-height: 30px; | |
} | |
/* ํค๋ ์คํ์ผ๋ง */ | |
h1 { | |
background: linear-gradient(120deg, #a855f7, #ec4899); | |
-webkit-background-clip: text; | |
-webkit-text-fill-color: transparent; | |
font-size: 3rem !important; | |
font-weight: 700 !important; | |
text-align: center; | |
margin-bottom: 1rem !important; | |
} | |
/* ์นด๋ ์คํ์ผ */ | |
.stat-card { | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
padding: 1rem; | |
border-radius: 12px; | |
color: white; | |
text-align: center; | |
height: 100px; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
box-shadow: 0 4px 15px rgba(0,0,0,0.1); | |
} | |
.stat-number { | |
font-size: 2rem; | |
margin-bottom: 0.3rem; | |
} | |
.stat-label { | |
font-size: 0.85rem; | |
opacity: 0.95; | |
} | |
/* ๋ฒํผ ์คํ์ผ ๊ฐ์ */ | |
.stButton > button { | |
background: linear-gradient(135deg, #a855f7 0%, #ec4899 100%); | |
color: white; | |
border: none; | |
padding: 0.7rem 1.5rem; | |
font-size: 1rem; | |
font-weight: 600; | |
border-radius: 25px; | |
width: 100%; | |
transition: transform 0.2s; | |
} | |
.stButton > button:hover { | |
transform: translateY(-2px); | |
} | |
/* ์ฌ์ด๋๋ฐ ์คํ์ผ */ | |
.css-1d391kg { | |
background-color: #f8f7ff; | |
} | |
/* Info ๋ฐ์ค */ | |
.info-box { | |
background: #f0f4ff; | |
border-left: 4px solid #667eea; | |
padding: 1rem; | |
border-radius: 8px; | |
margin: 1rem 0; | |
} | |
</style> | |
""", unsafe_allow_html=True) | |
# ============================== | |
# Header Section | |
# ============================== | |
st.markdown("<h1>๐ Ansim Blur</h1>", unsafe_allow_html=True) | |
st.markdown("<p style='text-align: center; color: #6b7280; margin-bottom: 1rem;'>Advanced Face Privacy Protection</p>", unsafe_allow_html=True) | |
# Discord ๋ฐฐ์ง๋ฅผ ๊ฐ์ด๋ฐ ์ ๋ ฌ | |
st.markdown(""" | |
<div style='text-align: center; margin-bottom: 2rem;'> | |
<a href="https://discord.gg/openfreeai" target="_blank"> | |
<img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge" alt="Discord badge"> | |
</a> | |
</div> | |
""", unsafe_allow_html=True) | |
# Stats ์นด๋ - ์ปจํ ์ด๋๋ก ๊ณ ์ | |
stats_container = st.container() | |
with stats_container: | |
col1, col2, col3, col4 = st.columns(4) | |
with col1: | |
st.markdown("""<div class='stat-card'><div class='stat-number'>๐ผ๏ธ</div><div class='stat-label'>Image Support</div></div>""", unsafe_allow_html=True) | |
with col2: | |
st.markdown("""<div class='stat-card'><div class='stat-number'>๐ฅ</div><div class='stat-label'>Video Processing</div></div>""", unsafe_allow_html=True) | |
with col3: | |
st.markdown("""<div class='stat-card'><div class='stat-number'>โก</div><div class='stat-label'>Real-time</div></div>""", unsafe_allow_html=True) | |
with col4: | |
st.markdown("""<div class='stat-card'><div class='stat-number'>๐ก๏ธ</div><div class='stat-label'>Privacy First</div></div>""", unsafe_allow_html=True) | |
st.markdown("---") | |
# ============================== | |
# Model loader | |
# ============================== | |
def load_model(model_path: str = "yolov8-face-hf.pt", device: Optional[str] = None): | |
from ultralytics import YOLO | |
if device is None: | |
if torch.cuda.is_available(): | |
device = "cuda" | |
elif torch.backends.mps.is_available(): | |
device = "mps" | |
else: | |
device = "cpu" | |
model = YOLO(model_path) | |
model.to(device) | |
return model, device | |
with st.spinner("Loading AI model..."): | |
model, device = load_model() | |
# ============================== | |
# Sidebar - ๊ณ ์ ๋ ์ค์ | |
# ============================== | |
with st.sidebar: | |
st.markdown("## โ๏ธ Configuration") | |
st.info(f"Device: **{device.upper()}**") | |
st.markdown("### Detection Settings") | |
conf = st.slider("Confidence Threshold", 0.05, 0.9, 0.25, 0.01) | |
iou = st.slider("NMS IoU", 0.1, 0.9, 0.45, 0.01) | |
expand_ratio = st.slider("Box Expansion", 0.0, 0.5, 0.05, 0.01) | |
st.markdown("### Blur Settings") | |
mode_choice = st.selectbox("Style", ["Gaussian Blur", "Mosaic Effect"]) | |
if mode_choice == "Gaussian Blur": | |
blur_kernel = st.slider("Blur Intensity", 15, 151, 51, 2) | |
mosaic = 15 | |
else: | |
mosaic = st.slider("Mosaic Size", 5, 40, 15, 1) | |
blur_kernel = 51 | |
use_half = st.checkbox("Half Precision (CUDA)", value=False) | |
# ============================== | |
# Helper functions | |
# ============================== | |
def _ensure_odd(x: int) -> int: | |
return x if x % 2 == 1 else x + 1 | |
def _choose_writer_size(w: int, h: int) -> Tuple[int, int]: | |
return (w if w % 2 == 0 else w - 1, h if h % 2 == 0 else h - 1) | |
def _apply_anonymization(face_roi: np.ndarray, mode: str, blur_kernel: int, mosaic: int = 15) -> np.ndarray: | |
if face_roi.size == 0: | |
return face_roi | |
if mode == "Gaussian Blur": | |
k = _ensure_odd(max(blur_kernel, 15)) | |
return cv2.GaussianBlur(face_roi, (k, k), 0) | |
else: | |
m = max(2, mosaic) | |
h, w = face_roi.shape[:2] | |
face_small = cv2.resize(face_roi, (max(1, w // m), max(1, h // m)), interpolation=cv2.INTER_LINEAR) | |
return cv2.resize(face_small, (w, h), interpolation=cv2.INTER_NEAREST) | |
def blur_faces_image(image_bgr, conf, iou, expand_ratio, mode, blur_kernel, mosaic, use_half): | |
h, w = image_bgr.shape[:2] | |
face_count = 0 | |
with torch.no_grad(): | |
if use_half and device == "cuda": | |
torch.set_default_dtype(torch.float16) | |
results = model.predict(image_bgr, conf=conf, iou=iou, verbose=False, device=device) | |
if use_half and device == "cuda": | |
torch.set_default_dtype(torch.float32) | |
for r in results: | |
boxes = r.boxes.xyxy.cpu().numpy() if hasattr(r.boxes, "xyxy") else [] | |
face_count = len(boxes) | |
for x1, y1, x2, y2 in boxes: | |
x1, y1, x2, y2 = map(int, [x1, y1, x2, y2]) | |
if expand_ratio > 0: | |
bw = x2 - x1 | |
bh = y2 - y1 | |
dx = int(bw * expand_ratio) | |
dy = int(bh * expand_ratio) | |
x1 -= dx; y1 -= dy; x2 += dx; y2 += dy | |
x1 = max(0, min(w, x1)) | |
x2 = max(0, min(w, x2)) | |
y1 = max(0, min(h, y1)) | |
y2 = max(0, min(h, y2)) | |
if x2 <= x1 or y2 <= y1: | |
continue | |
roi = image_bgr[y1:y2, x1:x2] | |
image_bgr[y1:y2, x1:x2] = _apply_anonymization(roi, mode, blur_kernel, mosaic) | |
return image_bgr, face_count | |
def blur_faces_video(input_path, output_path, conf, iou, expand_ratio, mode, blur_kernel, mosaic, update_callback, use_half): | |
cap = cv2.VideoCapture(input_path) | |
if not cap.isOpened(): | |
raise IOError("Cannot open video") | |
in_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) | |
in_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) | |
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 | |
frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0 | |
out_w, out_h = _choose_writer_size(in_w, in_h) | |
fourcc = cv2.VideoWriter_fourcc(*"mp4v") | |
temp_video_path = str(Path(output_path).with_name("blurred_temp_video.mp4")) | |
out = cv2.VideoWriter(temp_video_path, fourcc, fps, (out_w, out_h)) | |
idx = 0 | |
total_faces = 0 | |
try: | |
while True: | |
ret, frame = cap.read() | |
if not ret: | |
break | |
frame = cv2.resize(frame, (out_w, out_h)) | |
with torch.no_grad(): | |
if use_half and device == "cuda": | |
torch.set_default_dtype(torch.float16) | |
results = model.predict(frame, conf=conf, iou=iou, verbose=False, device=device) | |
if use_half and device == "cuda": | |
torch.set_default_dtype(torch.float32) | |
h, w = frame.shape[:2] | |
r0 = results[0] if len(results) else None | |
boxes = r0.boxes.xyxy if (r0 and hasattr(r0, "boxes")) else [] | |
total_faces += len(boxes) | |
for b in boxes: | |
x1, y1, x2, y2 = map(int, b) | |
if expand_ratio > 0: | |
bw = x2 - x1 | |
bh = y2 - y1 | |
dx = int(bw * expand_ratio) | |
dy = int(bh * expand_ratio) | |
x1 -= dx; y1 -= dy; x2 += dx; y2 += dy | |
x1 = max(0, min(w, x1)) | |
x2 = max(0, min(w, x2)) | |
y1 = max(0, min(h, y1)) | |
y2 = max(0, min(h, y2)) | |
if x2 <= x1 or y2 <= y1: | |
continue | |
roi = frame[y1:y2, x1:x2] | |
frame[y1:y2, x1:x2] = _apply_anonymization(roi, mode, blur_kernel, mosaic) | |
out.write(frame) | |
idx += 1 | |
if update_callback and frames > 0: | |
update_callback(min(0.98, idx / frames), idx, frames, total_faces) | |
finally: | |
cap.release() | |
out.release() | |
try: | |
if update_callback: | |
update_callback(0.99, idx, frames, total_faces) | |
original = VideoFileClip(input_path) | |
processed = VideoFileClip(temp_video_path).set_audio(original.audio) | |
processed.write_videofile( | |
output_path, | |
codec="libx264", | |
audio_codec="aac", | |
threads=1, | |
logger=None | |
) | |
if update_callback: | |
update_callback(1.0, idx, frames, total_faces) | |
return output_path, total_faces | |
except Exception as e: | |
print("Audio merging failed:", e) | |
return temp_video_path, total_faces | |
# ============================== | |
# Main Interface - ๊ณ ์ ๋ ๋ ์ด์์ | |
# ============================== | |
tab1, tab2 = st.tabs(["๐ธ Image Processing", "๐ฌ Video Processing"]) | |
with tab1: | |
# ๊ณ ์ ๋ ์ปจํ ์ด๋ ์์ฑ | |
main_container = st.container() | |
with main_container: | |
# 2๊ฐ์ ๊ณ ์ ๋ ์ปฌ๋ผ | |
col1, col2 = st.columns(2, gap="large") | |
# ์ผ์ชฝ ์ปฌ๋ผ - ์ ๋ ฅ | |
with col1: | |
st.markdown("### Input") | |
# ํ์ผ ์ ๋ก๋ ์ปจํ ์ด๋ | |
upload_container = st.container() | |
with upload_container: | |
uploaded_file = st.file_uploader( | |
"Choose an image", | |
type=["jpg", "png", "jpeg"], | |
key="img_upload" | |
) | |
# ์๋ณธ ์ด๋ฏธ์ง ํ์ ์์ญ (๊ณ ์ ๋์ด) | |
original_placeholder = st.empty() | |
info_placeholder = st.empty() | |
if uploaded_file: | |
file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8) | |
image = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR) | |
original_placeholder.image(cv2.cvtColor(image, cv2.COLOR_BGR2RGB), caption="Original", use_container_width=True) | |
h, w = image.shape[:2] | |
info_placeholder.info(f"Size: {w} ร {h} pixels") | |
else: | |
# ๋น ๊ณต๊ฐ ์ ์ง | |
original_placeholder.markdown("<div class='image-container'><p style='text-align:center;color:#999;'>No image uploaded</p></div>", unsafe_allow_html=True) | |
info_placeholder.empty() | |
# ์ค๋ฅธ์ชฝ ์ปฌ๋ผ - ๊ฒฐ๊ณผ | |
with col2: | |
st.markdown("### Result") | |
# ๋ฒํผ ์ปจํ ์ด๋ | |
button_container = st.container() | |
# ๊ฒฐ๊ณผ ์ด๋ฏธ์ง ํ์ ์์ญ (๊ณ ์ ๋์ด) | |
result_placeholder = st.empty() | |
success_placeholder = st.empty() | |
download_placeholder = st.empty() | |
with button_container: | |
if uploaded_file: | |
if st.button("๐ Process Image", type="primary", use_container_width=True): | |
with st.spinner("Processing..."): | |
result, face_count = blur_faces_image( | |
image.copy(), conf, iou, expand_ratio, | |
mode_choice, blur_kernel, mosaic, use_half | |
) | |
result_placeholder.image(cv2.cvtColor(result, cv2.COLOR_BGR2RGB), caption="Processed", use_container_width=True) | |
success_placeholder.success(f"Blurred {face_count} face(s)") | |
_, buffer = cv2.imencode('.jpg', result) | |
download_placeholder.download_button( | |
"โฌ๏ธ Download", | |
data=buffer.tobytes(), | |
file_name="blurred.jpg", | |
mime="image/jpeg", | |
use_container_width=True | |
) | |
else: | |
result_placeholder.markdown("<div class='image-container'><p style='text-align:center;color:#999;'>Results will appear here</p></div>", unsafe_allow_html=True) | |
with tab2: | |
video_container = st.container() | |
with video_container: | |
col1, col2 = st.columns(2, gap="large") | |
with col1: | |
st.markdown("### Input Video") | |
video_upload = st.file_uploader( | |
"Choose a video", | |
type=["mp4", "avi", "mov", "mkv"], | |
key="video_upload" | |
) | |
video_placeholder = st.empty() | |
if video_upload: | |
video_placeholder.video(video_upload) | |
else: | |
video_placeholder.markdown("<div class='image-container'><p style='text-align:center;color:#999;'>No video uploaded</p></div>", unsafe_allow_html=True) | |
with col2: | |
st.markdown("### Processed Video") | |
process_button = st.empty() | |
progress_placeholder = st.empty() | |
stats_placeholder = st.empty() | |
result_video_placeholder = st.empty() | |
download_video_placeholder = st.empty() | |
if video_upload: | |
if process_button.button("๐ฌ Process Video", type="primary", use_container_width=True): | |
# Save uploaded file | |
input_path = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4").name | |
with open(input_path, "wb") as f: | |
f.write(video_upload.read()) | |
output_path = str(Path(tempfile.gettempdir()) / "blurred_video.mp4") | |
def update_progress(value, current_frame=0, total_frames=0, faces=0): | |
percent = int(value * 100) | |
progress_placeholder.progress(value) | |
if total_frames > 0: | |
stats_placeholder.info(f"๐ Frame: {current_frame}/{total_frames} | Progress: {percent}% | Faces: {faces}") | |
try: | |
final_output, total_faces = blur_faces_video( | |
input_path, output_path, | |
conf=conf, iou=iou, expand_ratio=expand_ratio, | |
mode=mode_choice, blur_kernel=blur_kernel, | |
mosaic=mosaic, | |
update_callback=update_progress, use_half=use_half | |
) | |
stats_placeholder.success(f"โ Complete! Blurred {total_faces} faces.") | |
result_video_placeholder.video(final_output) | |
with open(final_output, "rb") as file: | |
download_video_placeholder.download_button( | |
"โฌ๏ธ Download Video", | |
file, | |
file_name="blurred_video.mp4", | |
mime="video/mp4", | |
use_container_width=True | |
) | |
except Exception as e: | |
stats_placeholder.error(f"โ Error: {e}") | |
finally: | |
if os.path.exists(input_path): | |
os.remove(input_path) | |
else: | |
result_video_placeholder.markdown("<div class='image-container'><p style='text-align:center;color:#999;'>Processed video will appear here</p></div>", unsafe_allow_html=True) | |