dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel.
This commit is contained in:
@@ -352,6 +352,14 @@ def _apply_transparency(png_bytes: bytes) -> bytes:
|
||||
_faceswapper = None
|
||||
_faceswapper_lock = threading.Lock()
|
||||
|
||||
# Dedicated single-worker pool for face-crop extraction. Running it here
|
||||
# instead of via FastAPI BackgroundTasks keeps the heavy insightface inference
|
||||
# (and its one-time model load) off the shared request threadpool, so quick
|
||||
# endpoints like /order stay responsive right after a "set preferred" click.
|
||||
# A single worker also serializes face jobs so a burst can't thrash the GPU.
|
||||
from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor
|
||||
_face_executor = _ThreadPoolExecutor(max_workers=1, thread_name_prefix="face")
|
||||
|
||||
_gfpgan = None
|
||||
_gfpgan_lock = threading.Lock()
|
||||
|
||||
@@ -595,10 +603,12 @@ def _faceswap_worker(job_id: str, model_filename: str, video_name: str, enhance:
|
||||
content_type='video',
|
||||
faceswap_source_video=video_name,
|
||||
source_refs=json.dumps([model_filename]),
|
||||
sort_order=database.get_next_sort_order(group_id),
|
||||
)
|
||||
|
||||
jobs[job_id]["status"] = "done"
|
||||
jobs[job_id]["output"] = out_name
|
||||
_invalidate_static()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[faceswap] error: {e}")
|
||||
@@ -745,9 +755,11 @@ def _faceswap_worker_ff(job_id: str, model_filename: str, video_name: str,
|
||||
out_name, filepath=out_path, group_id=group_id,
|
||||
content_type='video', faceswap_source_video=video_name,
|
||||
source_refs=json.dumps([model_filename]),
|
||||
sort_order=database.get_next_sort_order(group_id),
|
||||
)
|
||||
jobs[job_id]['status'] = 'done'
|
||||
jobs[job_id]['output'] = out_name
|
||||
_invalidate_static()
|
||||
|
||||
except Exception as e:
|
||||
print(f'[faceswap-ff] error: {e}')
|
||||
@@ -786,6 +798,33 @@ def _load_poses():
|
||||
return poses
|
||||
|
||||
|
||||
def _save_poses(poses: dict) -> None:
|
||||
"""Rewrite poses.md from a {name: {text, beta}} dict, round-tripping _load_poses' format.
|
||||
|
||||
Each pose is written as a ``# Name`` (or ``# Name (beta)``) header followed by its body.
|
||||
Body sentences separated by '. ' are written on their own lines to match the existing
|
||||
hand-authored style.
|
||||
"""
|
||||
poses_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "poses.md")
|
||||
blocks = []
|
||||
for name, entry in poses.items():
|
||||
name = str(name).strip()
|
||||
if not name:
|
||||
continue
|
||||
if isinstance(entry, dict):
|
||||
text = str(entry.get("text", "")).strip()
|
||||
beta = bool(entry.get("beta"))
|
||||
else:
|
||||
text = str(entry).strip()
|
||||
beta = False
|
||||
header = f"# {name}{' (beta)' if beta else ''}"
|
||||
# Split into readable lines on sentence boundaries without losing the period.
|
||||
body_lines = [s.strip() for s in re.split(r'(?<=\.)\s+', text) if s.strip()]
|
||||
blocks.append(header + "\n" + "\n".join(body_lines))
|
||||
with open(poses_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n\n".join(blocks) + ("\n" if blocks else ""))
|
||||
|
||||
|
||||
def _detect_has_background(pil: Image.Image) -> bool:
|
||||
"""Return False when the image has significant alpha transparency (background removed)."""
|
||||
if pil.mode != 'RGBA':
|
||||
@@ -1134,6 +1173,9 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
print(f"Database error in batch worker: {db_err}")
|
||||
|
||||
jobs[job_id]["done"] += 1
|
||||
# Regenerate static JSON so the frontend's polling picks up the
|
||||
# new image immediately (progressive refresh, not just at the end).
|
||||
_invalidate_static()
|
||||
except Exception as e:
|
||||
print(f"Error in batch for {fname} with prompt '{prompt}': {e}")
|
||||
jobs[job_id]["failed"] += 1
|
||||
@@ -1142,6 +1184,7 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
jobs[job_id]["failed"] += len(prompts)
|
||||
|
||||
jobs[job_id]["status"] = "done"
|
||||
_invalidate_static()
|
||||
|
||||
|
||||
def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], poses: list,
|
||||
@@ -1215,6 +1258,7 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
|
||||
jobs[job_id]["failed"] += 1
|
||||
|
||||
jobs[job_id]["status"] = "done"
|
||||
_invalidate_static()
|
||||
|
||||
|
||||
# --- routes -----------------------------------------------------------------
|
||||
@@ -1319,6 +1363,39 @@ def get_poses():
|
||||
return _load_poses()
|
||||
|
||||
|
||||
class PoseRequest(BaseModel):
|
||||
name: str
|
||||
text: str = ""
|
||||
beta: bool = False
|
||||
old_name: str | None = None # set to rename an existing pose
|
||||
|
||||
|
||||
@app.post("/poses")
|
||||
def save_pose(req: PoseRequest):
|
||||
"""Create, update, or rename a pose in poses.md."""
|
||||
name = req.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "Pose name is required")
|
||||
poses = _load_poses()
|
||||
# Rename: drop the old key (and preserve ordering by rebuilding).
|
||||
if req.old_name and req.old_name != name:
|
||||
poses.pop(req.old_name, None)
|
||||
poses[name] = {"text": req.text.strip(), "beta": bool(req.beta)}
|
||||
_save_poses(poses)
|
||||
return {"status": "success", "poses": poses}
|
||||
|
||||
|
||||
@app.delete("/poses/{name}")
|
||||
def delete_pose(name: str):
|
||||
"""Delete a pose from poses.md."""
|
||||
poses = _load_poses()
|
||||
if name not in poses:
|
||||
raise HTTPException(404, "Pose not found")
|
||||
poses.pop(name, None)
|
||||
_save_poses(poses)
|
||||
return {"status": "success", "poses": poses}
|
||||
|
||||
|
||||
@app.get("/batch/{job_id}")
|
||||
def get_batch(job_id: str):
|
||||
if job_id not in jobs:
|
||||
@@ -1797,6 +1874,7 @@ def _crop_to_bbox(pil_img: Image.Image, margin: int = 20, top_margin: int = 20,
|
||||
def _extract_face_bg(filename: str, fpath: str):
|
||||
"""Background task: detect largest face, crop with padding, save as {group_id}_face.png."""
|
||||
try:
|
||||
import cv2
|
||||
app_fa, _ = _load_faceswapper()
|
||||
bgr = cv2.imread(fpath)
|
||||
if bgr is None:
|
||||
@@ -1830,7 +1908,7 @@ def _extract_face_bg(filename: str, fpath: str):
|
||||
print(f"[extract-face] error for {filename}: {e}")
|
||||
|
||||
|
||||
def _process_upload(file_path: str, filename: str, prompts: list[str], name: str | None = None, group_id: str | None = None):
|
||||
def _process_upload(file_path: str, filename: str, prompts: list[str], name: str | None = None, group_id: str | None = None, poses: list[str] | None = None):
|
||||
output_dir = _load_output_dir()
|
||||
try:
|
||||
pil = Image.open(file_path)
|
||||
@@ -1870,10 +1948,14 @@ def _process_upload(file_path: str, filename: str, prompts: list[str], name: str
|
||||
|
||||
out_embedding = embeddings.generate_embedding(out_path)
|
||||
next_order = database.get_next_sort_order(group_id)
|
||||
# Persist the prompt (and pose name, when this output came from a named pose)
|
||||
# so the generation parameters survive in the DB / images.json.
|
||||
out_pose = poses[i] if (poses and i < len(poses)) else None
|
||||
database.upsert_person(
|
||||
out_name, filepath=out_path, name=auto_name,
|
||||
clip_description=clip_desc, embedding=out_embedding,
|
||||
group_id=group_id, sort_order=next_order,
|
||||
prompt=prompt, pose=out_pose,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error processing prompt '{prompt}' for {filename}: {e}")
|
||||
@@ -1989,7 +2071,7 @@ def unarchive_image(filename: str):
|
||||
|
||||
|
||||
@app.post("/images/{filename}/set-preferred")
|
||||
def set_image_preferred(filename: str, background_tasks: BackgroundTasks):
|
||||
def set_image_preferred(filename: str):
|
||||
"""Make this image sort_order=0 within its group, shifting others to 1,2,..."""
|
||||
person = database.get_person(filename)
|
||||
if not person:
|
||||
@@ -2003,21 +2085,34 @@ def set_image_preferred(filename: str, background_tasks: BackgroundTasks):
|
||||
_invalidate_static()
|
||||
fpath = os.path.join(_load_output_dir(), filename)
|
||||
if os.path.exists(fpath):
|
||||
background_tasks.add_task(_extract_face_bg, filename, fpath)
|
||||
_face_executor.submit(_extract_face_bg, filename, fpath)
|
||||
return {"filename": filename, "group_id": group_id}
|
||||
|
||||
|
||||
@app.post("/images/{filename}/extract-face")
|
||||
def extract_face_endpoint(filename: str, background_tasks: BackgroundTasks):
|
||||
def extract_face_endpoint(filename: str):
|
||||
"""Detect and crop the largest face from image; saves as {group_id}_face.png."""
|
||||
output_dir = _load_output_dir()
|
||||
fpath = os.path.join(output_dir, filename)
|
||||
if not os.path.exists(fpath):
|
||||
raise HTTPException(404, "not found")
|
||||
background_tasks.add_task(_extract_face_bg, filename, fpath)
|
||||
_face_executor.submit(_extract_face_bg, filename, fpath)
|
||||
return {"status": "queued", "filename": filename}
|
||||
|
||||
|
||||
@app.get("/faces/{group_id}")
|
||||
def face_status(group_id: str):
|
||||
"""Report whether a face crop exists for a group.
|
||||
|
||||
Face extraction runs asynchronously after "set preferred", so the studio
|
||||
polls this (over HTTP, which works even when the page is opened via file://)
|
||||
instead of guessing with an <img> load that 404s during the race window.
|
||||
"""
|
||||
face_fname = f"{group_id.replace('/', '_')}_face.png"
|
||||
face_path = os.path.join(_load_output_dir(), face_fname)
|
||||
return {"exists": os.path.exists(face_path), "filename": face_fname}
|
||||
|
||||
|
||||
@app.post("/images/{filename}/undress")
|
||||
def undress_image(filename: str, background_tasks: BackgroundTasks):
|
||||
"""Queue a generation using the undress prompt on the given image."""
|
||||
@@ -2255,6 +2350,29 @@ def _load_sam2():
|
||||
return _sam2_predictor
|
||||
|
||||
|
||||
def _person_mask_score(mask, h: int, w: int):
|
||||
"""Rate how much `mask` looks like a centered subject vs. the background.
|
||||
|
||||
The subject (person) sits in the middle of the frame and rarely fills the
|
||||
corners; the background is the opposite — it hugs the corners and is sparse
|
||||
in the center. So `center_cov - corner_cov` is strongly positive for a
|
||||
correct person mask and negative when SAM2 has selected the background
|
||||
instead (the inverted-mask failure mode).
|
||||
|
||||
Returns (score, center_cov, corner_cov), all floats in [-1, 1] / [0, 1].
|
||||
"""
|
||||
import numpy as np
|
||||
cy0, cy1 = int(h * 0.30), int(h * 0.70)
|
||||
cx0, cx1 = int(w * 0.30), int(w * 0.70)
|
||||
center_cov = float(mask[cy0:cy1, cx0:cx1].mean())
|
||||
cs = max(1, int(min(h, w) * 0.10))
|
||||
corner_cov = float(np.mean([
|
||||
mask[:cs, :cs].mean(), mask[:cs, -cs:].mean(),
|
||||
mask[-cs:, :cs].mean(), mask[-cs:, -cs:].mean(),
|
||||
]))
|
||||
return center_cov - corner_cov, center_cov, corner_cov
|
||||
|
||||
|
||||
def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
|
||||
"""Remove background with SAM2 bbox segmentation; fallback to rembg.
|
||||
|
||||
@@ -2291,7 +2409,26 @@ def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
|
||||
print("[sam2] no masks returned, falling back to rembg")
|
||||
return _apply_transparency(png_bytes)
|
||||
|
||||
best = masks[int(np.argmax(scores))]
|
||||
# Pick the candidate that best matches a centered subject rather than
|
||||
# blindly trusting argmax(scores): on busy or low-contrast backgrounds
|
||||
# the top-confidence SAM2 mask is sometimes the background itself.
|
||||
# Combine the centered-subject prior with SAM2's own confidence.
|
||||
best = None
|
||||
best_rank = -1e9
|
||||
for i in range(len(masks)):
|
||||
m = masks[i].astype(bool)
|
||||
psc, _, _ = _person_mask_score(m, h, w)
|
||||
rank = psc + 0.10 * float(scores[i])
|
||||
if rank > best_rank:
|
||||
best_rank, best = rank, m
|
||||
|
||||
# Inversion guard — the user's hint: the model is in the center. If the
|
||||
# chosen mask still covers the corners more than the center, SAM2 picked
|
||||
# the background; flip the alpha so the person stays opaque.
|
||||
psc, ccov, kcov = _person_mask_score(best, h, w)
|
||||
if psc < 0:
|
||||
print(f"[sam2] mask inverted (center {ccov:.0%} < corners {kcov:.0%}) — flipping alpha")
|
||||
best = ~best
|
||||
|
||||
# Sanity check: a person should cover 5 %–92 % of the frame
|
||||
coverage = float(best.sum()) / (h * w)
|
||||
@@ -2467,15 +2604,42 @@ class CropRequest(BaseModel):
|
||||
y1: int
|
||||
x2: int
|
||||
y2: int
|
||||
as_copy: bool = False # True → crop a fresh copy, leaving the original untouched
|
||||
|
||||
|
||||
@app.post("/images/{filename}/crop")
|
||||
def manual_crop_image(filename: str, req: CropRequest):
|
||||
"""Crop the image to the given pixel rectangle (in original image coordinates) in-place."""
|
||||
"""Crop the image to the given pixel rectangle (in original image coordinates).
|
||||
|
||||
By default the crop is applied in-place. When ``as_copy`` is set, a new copy is
|
||||
created first (referencing the original via ``source_refs``) and the crop is applied
|
||||
to that copy, so the original is preserved.
|
||||
"""
|
||||
person = database.get_person(filename)
|
||||
if not person or not person[5] or not os.path.exists(person[5]):
|
||||
raise HTTPException(404, "Image file not found")
|
||||
path = person[5]
|
||||
src_path = person[5]
|
||||
|
||||
if req.as_copy:
|
||||
# Mirror duplicate_image: copy file + register a DB row that points back to the original.
|
||||
from datetime import datetime as _dt
|
||||
output_dir = os.path.dirname(src_path)
|
||||
ext = os.path.splitext(filename)[1] or ".png"
|
||||
stem = os.path.splitext(filename)[0]
|
||||
ts = _dt.now().strftime("%Y%m%d_%H%M%S")
|
||||
new_filename = f"{ts}_crop_{stem}{ext}"
|
||||
path = os.path.join(output_dir, new_filename)
|
||||
shutil.copy2(src_path, path)
|
||||
database.upsert_person(
|
||||
new_filename, filepath=path, group_id=person[1],
|
||||
prompt=person[6], pose=person[7],
|
||||
has_background=person[11], has_clothing=person[13],
|
||||
source_refs=json.dumps([filename]), # original is the reference
|
||||
)
|
||||
else:
|
||||
new_filename = filename
|
||||
path = src_path
|
||||
|
||||
img = Image.open(path)
|
||||
w, h = img.size
|
||||
x1 = max(0, min(req.x1, w))
|
||||
@@ -2483,11 +2647,49 @@ def manual_crop_image(filename: str, req: CropRequest):
|
||||
x2 = max(0, min(req.x2, w))
|
||||
y2 = max(0, min(req.y2, h))
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
if req.as_copy:
|
||||
# Roll back the copy we just made so a bad rect doesn't leave an orphan.
|
||||
try:
|
||||
database.delete_person(new_filename)
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(400, "Invalid crop rectangle")
|
||||
cropped = img.crop((x1, y1, x2, y2))
|
||||
fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
|
||||
cropped.save(path, format=fmt)
|
||||
return {"status": "success", "filename": filename, "box": [x1, y1, x2, y2]}
|
||||
if req.as_copy:
|
||||
_invalidate_static()
|
||||
return {"status": "success", "filename": filename, "new_filename": new_filename,
|
||||
"new_url": f"/output/{new_filename}", "as_copy": req.as_copy,
|
||||
"box": [x1, y1, x2, y2]}
|
||||
|
||||
|
||||
class RotateRequest(BaseModel):
|
||||
degrees: int = 90 # clockwise rotation; must be a multiple of 90
|
||||
|
||||
|
||||
@app.post("/images/{filename}/rotate")
|
||||
def rotate_image(filename: str, req: RotateRequest):
|
||||
"""Rotate an image clockwise in 90° steps, in place (lossless transpose)."""
|
||||
person = database.get_person(filename)
|
||||
if not person or not person[5] or not os.path.exists(person[5]):
|
||||
raise HTTPException(404, "Image file not found")
|
||||
path = person[5]
|
||||
deg = req.degrees % 360
|
||||
if deg not in (0, 90, 180, 270):
|
||||
raise HTTPException(400, "degrees must be a multiple of 90")
|
||||
if deg:
|
||||
# PIL transpose is defined counter-clockwise; map clockwise degrees onto it.
|
||||
cw_to_transpose = {
|
||||
90: Image.Transpose.ROTATE_270,
|
||||
180: Image.Transpose.ROTATE_180,
|
||||
270: Image.Transpose.ROTATE_90,
|
||||
}
|
||||
img = Image.open(path).transpose(cw_to_transpose[deg])
|
||||
fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
|
||||
img.save(path, format=fmt)
|
||||
return {"status": "success", "filename": filename, "degrees": deg}
|
||||
|
||||
|
||||
@app.post("/images/{filename}/duplicate")
|
||||
|
||||
Reference in New Issue
Block a user