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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user