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 21:27:11 +02:00
parent 8df588e594
commit 54d96ef580
10 changed files with 1178 additions and 369 deletions

View File

@@ -993,6 +993,8 @@ def _move_to_trash(filepath: str):
# --- static data files -------------------------------------------------------
_static_write_lock = threading.Lock()
_invalidate_timer: "threading.Timer | None" = None
_invalidate_timer_lock = threading.Lock()
def _write_json(path: str, data) -> None:
@@ -1084,8 +1086,17 @@ def _write_all_static() -> None:
def _invalidate_static() -> None:
"""Spawn a daemon thread to regenerate all static data files (non-blocking)."""
threading.Thread(target=_write_all_static, daemon=True).start()
"""Coalesce rapid invalidation calls — restarts a 0.3 s debounce timer each time.
At most one _write_all_static() runs per quiet window, preventing thread floods
during batch jobs that call this after every single image."""
global _invalidate_timer
with _invalidate_timer_lock:
if _invalidate_timer is not None:
_invalidate_timer.cancel()
t = threading.Timer(0.3, _write_all_static)
t.daemon = True
t.start()
_invalidate_timer = t
# -----------------------------------------------------------------------------
@@ -1253,6 +1264,9 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
print(f"DB error in multi-ref: {db_err}")
jobs[job_id]["done"] += 1
# Regenerate static JSON so the frontend's polling picks up the new
# image immediately (progressive refresh, matching _batch_worker).
_invalidate_static()
except Exception as e:
print(f"Error in multi-ref for prompt '{prompt}': {e}")
jobs[job_id]["failed"] += 1
@@ -1734,7 +1748,9 @@ def merge_groups(req: MergeRequest):
except Exception as db_err:
print(f"Database error in merge: {db_err}")
_invalidate_static()
# Write synchronously: the frontend reloads images.json immediately after this
# returns, so an async rebuild would race and show the pre-merge grouping.
_write_all_static()
return {"group_id": gid, "files": req.filenames}
@@ -1750,7 +1766,7 @@ def extract_from_group(req: ExtractRequest):
except Exception as db_err:
print(f"Database error in extract: {db_err}")
_invalidate_static()
_write_all_static()
return {"filename": req.filename}
@@ -1928,6 +1944,10 @@ def _process_upload(file_path: str, filename: str, prompts: list[str], name: str
clip_description=clip_desc, tags=tags, embedding=embedding,
group_id=group_id, sort_order=0, has_clothing=has_clothing,
)
# Surface the new group with its base image right away — the pose/base-prompt
# generation below can take a while, and the user shouldn't wait for it to
# see the group land on the gallery.
_invalidate_static()
# 4. Crop if needed
cropped_pil = _crop_to_bbox(pil)
@@ -2162,16 +2182,38 @@ def remove_background(filename: str):
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]
with open(path, "rb") as f:
png_bytes = f.read()
transparent_png = _apply_transparency(png_bytes)
with open(path, "wb") as f:
f.write(transparent_png)
# Persist the state + refresh static data so the flag (and No-BG/Crop buttons)
# survive a page reload instead of reverting to has_background=True.
database.upsert_person(filename, has_background=False)
_invalidate_static()
return {"status": "success", "filename": filename, "has_background": False}
@app.post("/images/{filename}/invert-alpha")
def invert_alpha(filename: str):
"""Invert the alpha channel in place — recovers cases where background removal
kept the background and dropped the subject (the wrong segment)."""
import numpy as np
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]
img = Image.open(path).convert("RGBA")
arr = np.array(img)
arr[:, :, 3] = 255 - arr[:, :, 3]
Image.fromarray(arr, "RGBA").save(path, format="PNG")
database.upsert_person(filename, has_background=False)
_invalidate_static()
return {"status": "success", "filename": filename}
@@ -2747,6 +2789,358 @@ def sam2_check():
return {"sam2": predictor is not False and predictor is not None}
# --- 2D body-pose preview -----------------------------------------------------
# Estimates COCO-17 keypoints from the model image so the UI can overlay a
# posenet-style skeleton. Estimator is feature-detected: rtmlib (ONNX, reuses the
# already-installed onnxruntime) is preferred, mediapipe is a fallback. If neither
# is installed the endpoints report unavailable instead of erroring the request.
_pose_estimator = None # cached (callable, backend_name) or False if unavailable
_pose_lock = threading.Lock()
# COCO-17 keypoint names (the order rtmlib's Body model returns).
POSE_KEYPOINT_NAMES = [
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
"left_shoulder", "right_shoulder", "left_elbow", "right_elbow",
"left_wrist", "right_wrist", "left_hip", "right_hip",
"left_knee", "right_knee", "left_ankle", "right_ankle",
]
# Bone connections (index pairs into COCO-17) for drawing the skeleton.
POSE_SKELETON = [
(5, 7), (7, 9), (6, 8), (8, 10), # arms
(11, 13), (13, 15), (12, 14), (14, 16), # legs
(5, 6), (11, 12), (5, 11), (6, 12), # torso
(0, 1), (0, 2), (1, 3), (2, 4), (0, 5), (0, 6), # head/neck
]
# mediapipe Pose (33 landmarks) → COCO-17 index map.
_MP_TO_COCO = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
def _load_pose_estimator():
global _pose_estimator
if _pose_estimator is not None:
return _pose_estimator
with _pose_lock:
if _pose_estimator is not None:
return _pose_estimator
# Preferred: rtmlib (RTMPose, ONNX) — returns COCO-17 directly.
try:
from rtmlib import Body
import numpy as np
model = Body(mode="balanced", backend="onnxruntime", device="cpu")
def _infer_rtm(pil):
bgr = np.array(pil.convert("RGB"))[:, :, ::-1]
kpts, scores = model(bgr) # (N,17,2), (N,17)
people = []
for person_kpts, person_scores in zip(kpts, scores):
people.append([[float(x), float(y), float(s)]
for (x, y), s in zip(person_kpts, person_scores)])
return people
_pose_estimator = (_infer_rtm, "rtmlib")
print("[pose] using rtmlib (RTMPose)")
return _pose_estimator
except Exception as e:
print(f"[pose] rtmlib unavailable: {e}")
# Fallback: mediapipe Pose (single person, normalized landmarks).
try:
import mediapipe as mp
import numpy as np
mp_pose = mp.solutions.pose.Pose(static_image_mode=True, model_complexity=2)
def _infer_mp(pil):
rgb = np.array(pil.convert("RGB"))
h, w = rgb.shape[:2]
res = mp_pose.process(rgb)
if not res.pose_landmarks:
return []
lm = res.pose_landmarks.landmark
kpts = []
for mp_idx in _MP_TO_COCO:
p = lm[mp_idx]
kpts.append([float(p.x * w), float(p.y * h), float(p.visibility)])
return [kpts]
_pose_estimator = (_infer_mp, "mediapipe")
print("[pose] using mediapipe Pose")
return _pose_estimator
except Exception as e:
print(f"[pose] mediapipe unavailable: {e}")
_pose_estimator = False
return _pose_estimator
# --- pose similarity (descriptor + index) -------------------------------------
# Pose descriptors are normalized (translation + scale invariant) COCO-17 vectors,
# cached in <output>/_data/poses_index.json so we can rank library images by pose.
_POSE_MIN_SCORE = 0.3
# Left/right keypoint pairs for the mirror-invariant distance.
_POSE_MIRROR = [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15]
_pose_index_status = {"running": False, "done": 0, "total": 0}
def _pose_descriptor(keypoints):
"""Normalize one person's COCO-17 keypoints into a translation/scale-invariant
descriptor: {"vec": [34 floats], "vis": [17 ints]}. Returns None if too sparse."""
vis = [1 if (kp[2] >= _POSE_MIN_SCORE) else 0 for kp in keypoints]
if sum(vis) < 6:
return None
def _mid(a, b):
if vis[a] and vis[b]:
return ((keypoints[a][0] + keypoints[b][0]) / 2.0,
(keypoints[a][1] + keypoints[b][1]) / 2.0)
return None
hip = _mid(11, 12)
sho = _mid(5, 6)
center = hip or sho
if center is None:
return None
# Scale by torso length; fall back to keypoint spread if torso isn't visible.
if hip and sho:
scale = ((hip[0] - sho[0]) ** 2 + (hip[1] - sho[1]) ** 2) ** 0.5
else:
xs = [keypoints[i][0] for i in range(17) if vis[i]]
ys = [keypoints[i][1] for i in range(17) if vis[i]]
scale = max(max(xs) - min(xs), max(ys) - min(ys))
if not scale or scale < 1e-3:
return None
vec = []
for i in range(17):
if vis[i]:
vec.append((keypoints[i][0] - center[0]) / scale)
vec.append((keypoints[i][1] - center[1]) / scale)
else:
vec.extend([0.0, 0.0])
return {"vec": vec, "vis": vis}
def _pose_distance(a, b):
"""Weighted L2 between two descriptors over jointly-visible joints, taking the
min of the direct and left-right-mirrored comparison. Lower = more similar."""
def _dist(av, avis, bv, bvis, mirror):
total, n = 0.0, 0
for i in range(17):
j = _POSE_MIRROR[i] if mirror else i
if not (avis[i] and bvis[j]):
continue
bx = bv[j * 2] * (-1 if mirror else 1) # flip x when mirrored
by = bv[j * 2 + 1]
dx = av[i * 2] - bx
dy = av[i * 2 + 1] - by
total += dx * dx + dy * dy
n += 1
return (total / n) ** 0.5 if n >= 4 else float("inf")
direct = _dist(a["vec"], a["vis"], b["vec"], b["vis"], False)
mirror = _dist(a["vec"], a["vis"], b["vec"], b["vis"], True)
return min(direct, mirror)
def _best_person(people):
"""Pick the largest-bbox person from an estimator result (most prominent subject)."""
best, best_area = None, -1.0
for kpts in people:
xs = [k[0] for k in kpts if k[2] >= _POSE_MIN_SCORE]
ys = [k[1] for k in kpts if k[2] >= _POSE_MIN_SCORE]
if len(xs) < 2:
continue
area = (max(xs) - min(xs)) * (max(ys) - min(ys))
if area > best_area:
best, best_area = kpts, area
return best
def _pose_index_path():
return os.path.join(_load_output_dir(), "_data", "poses_index.json")
def _load_pose_index():
try:
with open(_pose_index_path(), "r") as f:
return json.load(f)
except Exception:
return {}
_pose_index_lock = threading.Lock()
def _save_pose_index_entry(filename, desc):
with _pose_index_lock:
idx = _load_pose_index()
idx[filename] = desc
os.makedirs(os.path.dirname(_pose_index_path()), exist_ok=True)
_write_json(_pose_index_path(), idx)
@app.get("/pose/check")
def pose_check():
"""Report whether a body-pose estimator is available (and which backend)."""
est = _load_pose_estimator()
if not est:
return {"available": False,
"hint": "pip install rtmlib onnxruntime (or: pip install mediapipe)"}
return {"available": True, "backend": est[1]}
@app.post("/images/{filename}/pose")
def estimate_pose(filename: str):
"""Estimate COCO-17 body keypoints for an image. Returns pixel-space keypoints
plus the skeleton edge list so the frontend can overlay a posenet-style preview."""
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")
est = _load_pose_estimator()
if not est:
raise HTTPException(501, "No pose estimator installed. Try: pip install rtmlib onnxruntime")
infer, backend = est
pil = Image.open(person[5]).convert("RGB")
try:
people = infer(pil)
except Exception as e:
raise HTTPException(500, f"Pose estimation failed: {e}")
# Cache the descriptor so "find similar pose" can rank this image later.
best = _best_person(people)
if best is not None:
desc = _pose_descriptor(best)
if desc is not None:
try:
_save_pose_index_entry(filename, desc)
except Exception as e:
print(f"[pose] index save failed for {filename}: {e}")
return {
"status": "success",
"backend": backend,
"width": pil.width,
"height": pil.height,
"names": POSE_KEYPOINT_NAMES,
"skeleton": POSE_SKELETON,
"people": people,
}
def _build_pose_index_task():
try:
est = _load_pose_estimator()
if not est:
return
infer, _ = est
output_dir = _load_output_dir()
with _pose_index_lock:
idx = _load_pose_index()
persons = database.list_persons()
todo = [p[0] for p in persons
if p[0] not in idx
and (p[12] or "image") != "video"
and p[0].lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))]
_pose_index_status.update(running=True, done=0, total=len(todo))
print(f"[pose] index build: {len(todo)} images to process")
dirty = 0
for fn in todo:
try:
fpath = os.path.join(output_dir, fn)
if os.path.exists(fpath):
best = _best_person(infer(Image.open(fpath).convert("RGB")))
desc = _pose_descriptor(best) if best is not None else None
if desc is not None:
idx[fn] = desc
dirty += 1
except Exception as e:
print(f"[pose] index error for {fn}: {e}")
_pose_index_status["done"] += 1
# Batch-flush every 50 to avoid O(n^2) full-file rewrites.
if dirty >= 50:
with _pose_index_lock:
_write_json(_pose_index_path(), idx)
dirty = 0
print(f"[pose] index progress: {_pose_index_status['done']}/{len(todo)}")
with _pose_index_lock:
_write_json(_pose_index_path(), idx)
print(f"[pose] index build complete: {len(idx)} entries")
except Exception as e:
print(f"[pose] index build failed: {e}")
finally:
_pose_index_status["running"] = False
@app.post("/pose/index")
def build_pose_index():
"""Compute pose descriptors for all library images lacking one (daemon thread)."""
if not _load_pose_estimator():
raise HTTPException(501, "No pose estimator installed. Try: pip install rtmlib onnxruntime")
if _pose_index_status.get("running"):
return {"status": "already_running", **_pose_index_status}
threading.Thread(target=_build_pose_index_task, daemon=True).start()
return {"status": "started"}
@app.get("/pose/index/status")
def pose_index_status():
idx = _load_pose_index()
return {**_pose_index_status, "indexed": len(idx)}
def _rank_similar_poses(query_desc, limit, exclude=None):
idx = _load_pose_index()
scored = []
for fn, desc in idx.items():
if fn == exclude or not desc or "vec" not in desc:
continue
d = _pose_distance(query_desc, desc)
if d != float("inf"):
scored.append((d, fn))
scored.sort(key=lambda x: x[0])
groups = get_groups() if scored else {}
return [{"filename": fn, "group_id": groups.get(fn), "distance": round(d, 4)}
for d, fn in scored[:limit]]
@app.get("/pose/similar/{filename}")
def similar_pose(filename: str, limit: int = 12):
"""Rank library images by pose similarity to the given image."""
idx = _load_pose_index()
query = idx.get(filename)
if query is None:
# Compute on demand (also caches it).
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")
est = _load_pose_estimator()
if not est:
raise HTTPException(501, "No pose estimator installed.")
best = _best_person(est[0](Image.open(person[5]).convert("RGB")))
query = _pose_descriptor(best) if best is not None else None
if query is None:
raise HTTPException(404, "No detectable pose in this image")
try:
_save_pose_index_entry(filename, query)
except Exception:
pass
return {"filename": filename, "similar": _rank_similar_poses(query, limit, exclude=filename)}
class PoseSimilarRequest(BaseModel):
keypoints: list[list[float]] # [[x,y,score], ...17] in image pixels
width: int = 0
height: int = 0
limit: int = 12
@app.post("/pose/similar")
def similar_pose_from_keypoints(req: PoseSimilarRequest):
"""Rank library images by similarity to a supplied (e.g. hand-edited) skeleton."""
query = _pose_descriptor(req.keypoints)
if query is None:
raise HTTPException(400, "Supplied pose is too sparse to match")
return {"similar": _rank_similar_poses(query, req.limit)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),