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:
mike
2026-06-24 13:10:07 +02:00
parent 42f924566e
commit 8df588e594
5 changed files with 1081 additions and 130 deletions

View File

@@ -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")