updates UI
This commit is contained in:
@@ -60,8 +60,11 @@ def get_processed_files():
|
|||||||
|
|
||||||
def save_processed_files(processed):
|
def save_processed_files(processed):
|
||||||
try:
|
try:
|
||||||
with open(CONF["processed_file"], 'w') as f:
|
p = CONF["processed_file"]
|
||||||
|
tmp = p + ".tmp"
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
json.dump(processed, f, indent=2)
|
json.dump(processed, f, indent=2)
|
||||||
|
os.replace(tmp, p)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error saving processed file: {e}")
|
logging.error(f"Error saving processed file: {e}")
|
||||||
|
|
||||||
@@ -253,9 +256,11 @@ def update_car_html():
|
|||||||
|
|
||||||
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||||
|
|
||||||
with open(car_html_path, 'w') as f:
|
tmp_path = car_html_path + ".tmp"
|
||||||
|
with open(tmp_path, 'w') as f:
|
||||||
f.write(new_content)
|
f.write(new_content)
|
||||||
logging.info(f"Updated {car_html_path} with {len(images)} images")
|
os.replace(tmp_path, car_html_path)
|
||||||
|
logging.info(f"Updated {car_html_path} atomically with {len(images)} images")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to update car.html: {e}")
|
logging.error(f"Failed to update car.html: {e}")
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ echo "Installing services: user=$SVC_USER group=$SVC_GROUP"
|
|||||||
echo " a6k=$A6K"
|
echo " a6k=$A6K"
|
||||||
echo " tour=$TOUR"
|
echo " tour=$TOUR"
|
||||||
|
|
||||||
for unit in comfyui-backend comfyui-api comfyui-watcher; do
|
for unit in comfyui-backend comfyui-api; do
|
||||||
sed -e "s|__USER__|$SVC_USER|g" \
|
sed -e "s|__USER__|$SVC_USER|g" \
|
||||||
-e "s|__GROUP__|$SVC_GROUP|g" \
|
-e "s|__GROUP__|$SVC_GROUP|g" \
|
||||||
-e "s|__A6K__|$A6K|g" \
|
-e "s|__A6K__|$A6K|g" \
|
||||||
@@ -36,10 +36,10 @@ echo "Reloading systemd daemon..."
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
|
||||||
echo "Enabling services + target..."
|
echo "Enabling services + target..."
|
||||||
systemctl enable comfyui-backend.service comfyui-api.service comfyui-watcher.service comfyui.target
|
systemctl enable comfyui-backend.service comfyui-api.service comfyui.target
|
||||||
|
|
||||||
echo "Starting system..."
|
echo "Starting system..."
|
||||||
systemctl start comfyui.target
|
systemctl start comfyui.target
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api comfyui-watcher"
|
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api"
|
||||||
|
|||||||
@@ -15,8 +15,15 @@ NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -t
|
|||||||
export COMFY_URL="http://127.0.0.1:8188"
|
export COMFY_URL="http://127.0.0.1:8188"
|
||||||
export HOST="0.0.0.0"
|
export HOST="0.0.0.0"
|
||||||
export PORT="8500"
|
export PORT="8500"
|
||||||
|
|
||||||
|
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
|
||||||
|
# This comfortably allows full-HD-ish outputs like 1920x1080.
|
||||||
|
# Override via MAX_AREA when needed.
|
||||||
|
export MAX_AREA="${MAX_AREA:-2097152}"
|
||||||
|
|
||||||
|
# @LEGACY PREVIOUS VERSION
|
||||||
# The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP
|
# The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP
|
||||||
# output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA.
|
# output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA.
|
||||||
export MAX_AREA="${MAX_AREA:-1048576}"
|
# export MAX_AREA="${MAX_AREA:-1048576}"
|
||||||
|
|
||||||
exec python edit_api.py
|
exec python edit_api.py
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Qwen-Image-Edit Folder Watcher (A6000)
|
|
||||||
After=comfyui-api.service
|
|
||||||
Requires=comfyui-api.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=__USER__
|
|
||||||
Group=__GROUP__
|
|
||||||
ExecStart=/bin/bash __TOUR__/start_watcher.sh
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=comfyui.target
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Qwen-Image-Edit Complete System (A6000)
|
Description=Afterimage (A6000)
|
||||||
Wants=comfyui-backend.service comfyui-api.service comfyui-watcher.service
|
Wants=comfyui-backend.service comfyui-api.service
|
||||||
After=comfyui-watcher.service
|
After=comfyui-backend.service comfyui-api.service
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
19
backlog.md
19
backlog.md
@@ -82,7 +82,21 @@ Scenery:
|
|||||||
- tab "SAM" checkerboard icon to studio-view overlay
|
- tab "SAM" checkerboard icon to studio-view overlay
|
||||||
- in the landing page hide SOURCE images
|
- in the landing page hide SOURCE images
|
||||||
- als het input bestand real is, dan gaat echt elke opvolging perfect. supe plaatjes.. (3 kwart maakt de opvolgende plaatjes ook altijd engaging.. top down (vanwege ruimte))
|
- als het input bestand real is, dan gaat echt elke opvolging perfect. supe plaatjes.. (3 kwart maakt de opvolgende plaatjes ook altijd engaging.. top down (vanwege ruimte))
|
||||||
-
|
- When open the app via the launcher, make it privacy enabled by default, so the privacy screen will appear.
|
||||||
|
- When open a group by default hide orbit images, videos
|
||||||
|
- Do not show videos in the landingpage image-loop
|
||||||
|
- Make - html per group and html for the landing page. Decouple the landingpage from the studio and studio is hydrated available per group
|
||||||
|
- update svg
|
||||||
|
- Extract the heavy model API, so we can unit-test it. ( and we can stand-alone disable it ) and doesnt require the entire 41gb model to load into memory for a simple backend update
|
||||||
|
- Save pose-prompt history + refine + reverse engineer + Scenery prompt in database. create a new db table called prompt, add the type of prompt for exmaple refine or scene to the record and all relevant information used to recontruct the prompt input. so also include a jsonb for metadata/tags etc.
|
||||||
|
- In order to decouple the model-engine (by design it can be turned off) and the backend (by design it can be turned off)
|
||||||
|
- in the UI right top make two dots indicating the model-engine, backend is online and if its (actively processing a task), use the healthchecks of the services to check realtime status.
|
||||||
|
- in case the Model-engine is offline, we want to disable the UI-actions that require the model-engine, such as pose generation,
|
||||||
|
- in case the backend is offline, we want to disable the UI-actions that require the backend, such as duplicate, pad, crop, etc..
|
||||||
|
Start by implmenting the mechanics of the status leds in the UI. and disable one or two features that require the model-engine or the backend to be online. We complete the task after review
|
||||||
|
- in order to decouple the landingpage/dashboard from the studio view (editor) we are going to generate group "shoot" specific html pages. Start by just generate the studio portion of car.html into the group specifc html pages. It does not need to be synchronous, but when data of the group is changing we should update the group specifc data json file too, we copy the goup files to the output folder next to car.html
|
||||||
|
- in /mnt/zim/tour-comfy/output/_turntable we have data regarding orbit generations. We want to store the orbit data in the database, start by adding the corresponding tags and meta info to the images (currently stored in the person table (but actually are images with metadata))
|
||||||
|
- in the ui - studio view we have an orbit filter. Currently the filter is filtering too many files, also references of orbit files are being marked orbit. When an orbit file is used, the metadata should not include the orbit tag.
|
||||||
## refine
|
## refine
|
||||||
|
|
||||||
- when refresh page, we lose track of current jobs running.
|
- when refresh page, we lose track of current jobs running.
|
||||||
@@ -129,7 +143,8 @@ TRACKING
|
|||||||
4) add a new video filter, and hide video by default in the Group Active
|
4) add a new video filter, and hide video by default in the Group Active
|
||||||
-- feel free to furhter optimize the studio view "info" tab
|
-- feel free to furhter optimize the studio view "info" tab
|
||||||
|
|
||||||
1) for every image we have an entry in the database, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. .
|
so we have a postgres-db, the frontend car.html and the backend edit_api.py
|
||||||
|
1) for every image we have an entry in the database table person, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. .
|
||||||
In the app we looking at groups of images, so also keep track of the group, this include the additional functional features we show in the UI for that group with the images it includes. Keep the data up-to-date in the backend when data changes, also update the referring data files.
|
In the app we looking at groups of images, so also keep track of the group, this include the additional functional features we show in the UI for that group with the images it includes. Keep the data up-to-date in the backend when data changes, also update the referring data files.
|
||||||
2) introduce a rating feature. to thumps up or thumbs down an image. When an image gets athamps up or thumbs down add a tag in the db and show it in the UI. Also the default filter in "Group Active" should filter out the images with a negative rating, but also include a filter that would show only images with a positive rating. The rating should also cascade (calculated? perhaps) into the poses. So images with pose that have good rating will define the rating of a pose, same for the image group (use a clever normalized formula ) over the positive and negative thumbs to compare it with another group
|
2) introduce a rating feature. to thumps up or thumbs down an image. When an image gets athamps up or thumbs down add a tag in the db and show it in the UI. Also the default filter in "Group Active" should filter out the images with a negative rating, but also include a filter that would show only images with a positive rating. The rating should also cascade (calculated? perhaps) into the poses. So images with pose that have good rating will define the rating of a pose, same for the image group (use a clever normalized formula ) over the positive and negative thumbs to compare it with another group
|
||||||
3) introduce (?cosine) similarity variation indexes in a group. Also for the faces in a group. To keep track of authenticity of a person in a group
|
3) introduce (?cosine) similarity variation indexes in a group. Also for the faces in a group. To keep track of authenticity of a person in a group
|
||||||
|
|||||||
22
studio.sh
Executable file
22
studio.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Launch the studio UI in a clean Chrome profile (no stale cache)
|
||||||
|
TMPDIR=$(mktemp -d /tmp/chrome-studio-XXXXXX)
|
||||||
|
trap "rm -rf '$TMPDIR'" EXIT
|
||||||
|
|
||||||
|
DRI_PRIME=pci-0000_02_00_0 \
|
||||||
|
google-chrome \
|
||||||
|
--disable-web-security \
|
||||||
|
--allow-file-access-from-files \
|
||||||
|
--user-data-dir="$TMPDIR" \
|
||||||
|
--disable-dev-shm-usage \
|
||||||
|
--no-first-run \
|
||||||
|
--no-default-browser-check \
|
||||||
|
--disable-infobars \
|
||||||
|
--test-type \
|
||||||
|
--ozone-platform=x11 \
|
||||||
|
--disable-vulkan \
|
||||||
|
--use-gl=desktop \
|
||||||
|
--log-level=3 \
|
||||||
|
--silent-debugger-extension-api \
|
||||||
|
--app="file:///mnt/zim/tour-comfy/output/car.html" \
|
||||||
|
2>/dev/null
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,17 @@ def migrate_schema():
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
|
# Create prompt table first
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS prompt (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
prompt_text TEXT NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_prompt_type_text UNIQUE (type, prompt_text)
|
||||||
|
)
|
||||||
|
""")
|
||||||
for sql in [
|
for sql in [
|
||||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT",
|
"ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT",
|
||||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT",
|
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT",
|
||||||
@@ -262,6 +273,11 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
|
|||||||
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction,
|
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction,
|
||||||
json.dumps(objects) if objects else None))
|
json.dumps(objects) if objects else None))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Sync after commit
|
||||||
|
try:
|
||||||
|
sync_by_filename_async(filename)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
@@ -272,6 +288,10 @@ def set_archived(filename, archived: bool):
|
|||||||
try:
|
try:
|
||||||
cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename))
|
cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
try:
|
||||||
|
sync_by_filename_async(filename)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
@@ -286,6 +306,11 @@ def set_filenames_archived(filenames, archived: bool):
|
|||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
updated = [r[0] for r in rows]
|
updated = [r[0] for r in rows]
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
for fn in updated:
|
||||||
|
try:
|
||||||
|
sync_by_filename_async(fn)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return updated
|
return updated
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -297,6 +322,10 @@ def set_hidden(filename, hidden: bool):
|
|||||||
try:
|
try:
|
||||||
cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename))
|
cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
try:
|
||||||
|
sync_by_filename_async(filename)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
@@ -307,6 +336,10 @@ def set_person_tags(filename, tags):
|
|||||||
try:
|
try:
|
||||||
cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename))
|
cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
try:
|
||||||
|
sync_by_filename_async(filename)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
@@ -363,21 +396,56 @@ def search_similar(embedding, limit=10):
|
|||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
|
|
||||||
def delete_person(filename):
|
def delete_person(filename):
|
||||||
|
person = get_person(filename)
|
||||||
|
gid = person[1] if person else None
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
cur.execute("DELETE FROM person WHERE filename = %s", (filename,))
|
cur.execute("DELETE FROM person WHERE filename = %s", (filename,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Delete JSON file on disk
|
||||||
|
try:
|
||||||
|
out_dir = _get_output_dir()
|
||||||
|
base, _ = os.path.splitext(filename)
|
||||||
|
json_path = os.path.join(out_dir, base + ".json")
|
||||||
|
if os.path.exists(json_path):
|
||||||
|
os.remove(json_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if gid:
|
||||||
|
try:
|
||||||
|
sync_group_to_json_async(gid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
|
|
||||||
def delete_group(group_id):
|
def delete_group(group_id):
|
||||||
|
files = get_group_files(group_id)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,))
|
cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Delete JSON files on disk
|
||||||
|
out_dir = _get_output_dir()
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
base, _ = os.path.splitext(f[0])
|
||||||
|
json_path = os.path.join(out_dir, base + ".json")
|
||||||
|
if os.path.exists(json_path):
|
||||||
|
os.remove(json_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
group_json_path = os.path.join(out_dir, "_data", f"group_{group_id}.json")
|
||||||
|
if os.path.exists(group_json_path):
|
||||||
|
os.remove(group_json_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
@@ -431,6 +499,13 @@ def set_group_order(group_id, ordered_filenames):
|
|||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sync_group_to_json_async(group_id)
|
||||||
|
for fname in ordered_filenames:
|
||||||
|
sync_person_to_json_async(fname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def get_group_order(group_id):
|
def get_group_order(group_id):
|
||||||
"""Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST."""
|
"""Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST."""
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -454,6 +529,13 @@ def set_group_name(group_id, name):
|
|||||||
try:
|
try:
|
||||||
cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id))
|
cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
try:
|
||||||
|
sync_group_to_json_async(group_id)
|
||||||
|
files = get_group_files(group_id)
|
||||||
|
for f in files:
|
||||||
|
sync_person_to_json_async(f[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
@@ -560,3 +642,367 @@ def invalidate_all_metadata():
|
|||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
_put_db_connection(conn)
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
|
||||||
|
def _get_output_dir():
|
||||||
|
# Load config.json to find output_dir
|
||||||
|
config_paths = ["config.json", "tour-comfy/config.json", "../config.json"]
|
||||||
|
for p in config_paths:
|
||||||
|
if os.path.exists(p):
|
||||||
|
try:
|
||||||
|
with open(p, "r") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
if "output_dir" in cfg:
|
||||||
|
return cfg["output_dir"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "../output" # Default fallback
|
||||||
|
|
||||||
|
def sync_person_to_json(filename):
|
||||||
|
"""Write/sync a detailed .json file with the DB fields of the specified filename."""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT filename, filepath, name, group_id, tags, embedding,
|
||||||
|
clip_description, prompt, pose, sort_order, group_name, hidden,
|
||||||
|
has_background, source_refs, has_clothing, content_type,
|
||||||
|
faceswap_source_video, archived, face_embedding, is_source,
|
||||||
|
pose_description, pose_skeleton, people_count, anatomical_completeness,
|
||||||
|
facial_direction, objects
|
||||||
|
FROM person
|
||||||
|
WHERE filename = %s
|
||||||
|
""", (filename,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse tags
|
||||||
|
tags_val = row[4]
|
||||||
|
tags_list = []
|
||||||
|
if tags_val:
|
||||||
|
if isinstance(tags_val, str):
|
||||||
|
try: tags_list = json.loads(tags_val)
|
||||||
|
except Exception: tags_list = []
|
||||||
|
elif isinstance(tags_val, list):
|
||||||
|
tags_list = tags_val
|
||||||
|
|
||||||
|
# Parse objects
|
||||||
|
obj_val = row[25]
|
||||||
|
obj_list = []
|
||||||
|
if obj_val:
|
||||||
|
if isinstance(obj_val, str):
|
||||||
|
try: obj_list = json.loads(obj_val)
|
||||||
|
except Exception: obj_list = []
|
||||||
|
elif isinstance(obj_val, list):
|
||||||
|
obj_list = obj_val
|
||||||
|
|
||||||
|
# Parse source_refs
|
||||||
|
ref_val = row[13]
|
||||||
|
ref_list = []
|
||||||
|
if ref_val:
|
||||||
|
if isinstance(ref_val, str):
|
||||||
|
try: ref_list = json.loads(ref_val)
|
||||||
|
except Exception: ref_list = []
|
||||||
|
elif isinstance(ref_val, list):
|
||||||
|
ref_list = ref_val
|
||||||
|
|
||||||
|
# Represent embeddings as lists of floats (for json compatibility)
|
||||||
|
embedding_list = None
|
||||||
|
if row[5] is not None:
|
||||||
|
if isinstance(row[5], str):
|
||||||
|
embedding_list = [float(x) for x in row[5].strip("[]").split(",") if x.strip()]
|
||||||
|
else:
|
||||||
|
embedding_list = list(row[5])
|
||||||
|
|
||||||
|
face_embedding_list = None
|
||||||
|
if row[18] is not None:
|
||||||
|
if isinstance(row[18], str):
|
||||||
|
face_embedding_list = [float(x) for x in row[18].strip("[]").split(",") if x.strip()]
|
||||||
|
else:
|
||||||
|
face_embedding_list = list(row[18])
|
||||||
|
|
||||||
|
# Parse pose_skeleton
|
||||||
|
pose_skel = None
|
||||||
|
if row[21]:
|
||||||
|
try:
|
||||||
|
pose_skel = json.loads(row[21])
|
||||||
|
except Exception:
|
||||||
|
pose_skel = row[21]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"filename": row[0],
|
||||||
|
"filepath": row[1],
|
||||||
|
"name": row[2],
|
||||||
|
"group_id": row[3],
|
||||||
|
"tags": tags_list,
|
||||||
|
"embedding": embedding_list,
|
||||||
|
"clip_description": row[6],
|
||||||
|
"prompt": row[7],
|
||||||
|
"pose": row[8],
|
||||||
|
"sort_order": row[9],
|
||||||
|
"group_name": row[10],
|
||||||
|
"hidden": bool(row[11]) if row[11] is not None else False,
|
||||||
|
"has_background": bool(row[12]) if row[12] is not None else True,
|
||||||
|
"source_refs": ref_list,
|
||||||
|
"has_clothing": row[14],
|
||||||
|
"content_type": row[15] or "image",
|
||||||
|
"faceswap_source_video": row[16],
|
||||||
|
"archived": bool(row[17]) if row[17] is not None else False,
|
||||||
|
"face_embedding": face_embedding_list,
|
||||||
|
"is_source": bool(row[19]) if row[19] is not None else False,
|
||||||
|
"pose_description": row[20],
|
||||||
|
"pose_skeleton": pose_skel,
|
||||||
|
"people_count": row[22],
|
||||||
|
"anatomical_completeness": row[23],
|
||||||
|
"facial_direction": row[24],
|
||||||
|
"objects": obj_list
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine json path
|
||||||
|
out_dir = _get_output_dir()
|
||||||
|
if row[1] and os.path.isabs(row[1]):
|
||||||
|
# Use same directory as absolute filepath
|
||||||
|
base, _ = os.path.splitext(row[1])
|
||||||
|
json_path = base + ".json"
|
||||||
|
else:
|
||||||
|
# Fallback to output_dir
|
||||||
|
base, _ = os.path.splitext(row[0])
|
||||||
|
json_path = os.path.join(out_dir, base + ".json")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(json_path), exist_ok=True)
|
||||||
|
tmp_json_path = json_path + ".tmp"
|
||||||
|
with open(tmp_json_path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
os.replace(tmp_json_path, json_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[db] Error syncing person {filename} to JSON: {e}")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
def sync_group_to_json(group_id):
|
||||||
|
"""Sync all members of a group, calculate group rating and similarity variation,
|
||||||
|
and save to _data/group_<group_id>.json.
|
||||||
|
"""
|
||||||
|
if not group_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
# Fetch all members
|
||||||
|
cur.execute("""
|
||||||
|
SELECT filename, filepath, name, tags, embedding,
|
||||||
|
clip_description, prompt, pose, sort_order, group_name, hidden,
|
||||||
|
has_background, source_refs, content_type, faceswap_source_video,
|
||||||
|
archived, face_embedding, is_source, pose_description, pose_skeleton
|
||||||
|
FROM person
|
||||||
|
WHERE group_id = %s
|
||||||
|
ORDER BY sort_order ASC, filename ASC
|
||||||
|
""", (group_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
members = []
|
||||||
|
likes = 0
|
||||||
|
dislikes = 0
|
||||||
|
clip_embs = []
|
||||||
|
face_embs = []
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
tags_val = r[3]
|
||||||
|
tags_list = []
|
||||||
|
if tags_val:
|
||||||
|
if isinstance(tags_val, str):
|
||||||
|
try: tags_list = json.loads(tags_val)
|
||||||
|
except Exception: tags_list = []
|
||||||
|
elif isinstance(tags_val, list):
|
||||||
|
tags_list = tags_val
|
||||||
|
|
||||||
|
if "LIKE" in tags_list:
|
||||||
|
likes += 1
|
||||||
|
elif "DISLIKE" in tags_list:
|
||||||
|
dislikes += 1
|
||||||
|
|
||||||
|
# Parse embeddings for similarity
|
||||||
|
if r[4] is not None:
|
||||||
|
if isinstance(r[4], str):
|
||||||
|
clip_list = [float(x) for x in r[4].strip("[]").split(",") if x.strip()]
|
||||||
|
else:
|
||||||
|
clip_list = list(r[4])
|
||||||
|
if clip_list:
|
||||||
|
clip_embs.append(clip_list)
|
||||||
|
|
||||||
|
if r[16] is not None:
|
||||||
|
if isinstance(r[16], str):
|
||||||
|
face_list = [float(x) for x in r[16].strip("[]").split(",") if x.strip()]
|
||||||
|
else:
|
||||||
|
face_list = list(r[16])
|
||||||
|
if face_list:
|
||||||
|
face_embs.append(face_list)
|
||||||
|
|
||||||
|
members.append({
|
||||||
|
"filename": r[0],
|
||||||
|
"filepath": r[1],
|
||||||
|
"name": r[2],
|
||||||
|
"tags": tags_list,
|
||||||
|
"clip_description": r[5],
|
||||||
|
"prompt": r[6],
|
||||||
|
"pose": r[7],
|
||||||
|
"sort_order": r[8],
|
||||||
|
"hidden": bool(r[10]),
|
||||||
|
"archived": bool(r[15]),
|
||||||
|
"is_source": bool(r[17])
|
||||||
|
})
|
||||||
|
|
||||||
|
def cosine_similarity(v1, v2):
|
||||||
|
if not v1 or not v2: return 0.0
|
||||||
|
dot = sum(a * b for a, b in zip(v1, v2))
|
||||||
|
norm1 = math.sqrt(sum(a * a for a in v1))
|
||||||
|
norm2 = math.sqrt(sum(b * b for b in v2))
|
||||||
|
if norm1 == 0 or norm2 == 0: return 0.0
|
||||||
|
return dot / (norm1 * norm2)
|
||||||
|
|
||||||
|
def calc_stats(embs):
|
||||||
|
if len(embs) < 2:
|
||||||
|
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
|
||||||
|
similarities = []
|
||||||
|
for i in range(len(embs)):
|
||||||
|
for j in range(i + 1, len(embs)):
|
||||||
|
similarities.append(cosine_similarity(embs[i], embs[j]))
|
||||||
|
if not similarities:
|
||||||
|
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
|
||||||
|
avg_sim = sum(similarities) / len(similarities)
|
||||||
|
min_sim = min(similarities)
|
||||||
|
max_sim = max(similarities)
|
||||||
|
variance = sum((s - avg_sim) ** 2 for s in similarities) / len(similarities)
|
||||||
|
return {
|
||||||
|
"average": float(avg_sim),
|
||||||
|
"min": float(min_sim),
|
||||||
|
"max": float(max_sim),
|
||||||
|
"variation": float(math.sqrt(variance)),
|
||||||
|
"pairs_count": len(similarities)
|
||||||
|
}
|
||||||
|
|
||||||
|
group_name = rows[0][9] if rows else group_id
|
||||||
|
|
||||||
|
group_data = {
|
||||||
|
"group_id": group_id,
|
||||||
|
"group_name": group_name,
|
||||||
|
"rating": {
|
||||||
|
"likes": likes,
|
||||||
|
"dislikes": dislikes,
|
||||||
|
"score": likes - dislikes,
|
||||||
|
"normalized_score": (likes - dislikes) / (likes + dislikes + 2.0) if (likes + dislikes) > 0 else 0.0,
|
||||||
|
"ratio": likes / (likes + dislikes) if (likes + dislikes) > 0 else 0.0
|
||||||
|
},
|
||||||
|
"clip_similarity_stats": calc_stats(clip_embs),
|
||||||
|
"face_similarity_stats": calc_stats(face_embs),
|
||||||
|
"members": members
|
||||||
|
}
|
||||||
|
|
||||||
|
out_dir = _get_output_dir()
|
||||||
|
data_dir = os.path.join(out_dir, "_data")
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
|
||||||
|
json_path = os.path.join(data_dir, f"group_{group_id}.json")
|
||||||
|
tmp_json_path = json_path + ".tmp"
|
||||||
|
with open(tmp_json_path, "w") as f:
|
||||||
|
json.dump(group_data, f, indent=2)
|
||||||
|
os.replace(tmp_json_path, json_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[db] Error syncing group {group_id} to JSON: {e}")
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
def sync_by_filename(filename):
|
||||||
|
sync_person_to_json(filename)
|
||||||
|
person = get_person(filename)
|
||||||
|
if person and person[1]:
|
||||||
|
sync_group_to_json(person[1])
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
_sync_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="db_sync_worker")
|
||||||
|
|
||||||
|
def sync_by_filename_async(filename):
|
||||||
|
_sync_executor.submit(sync_by_filename, filename)
|
||||||
|
|
||||||
|
def sync_group_to_json_async(group_id):
|
||||||
|
_sync_executor.submit(sync_group_to_json, group_id)
|
||||||
|
|
||||||
|
def sync_person_to_json_async(filename):
|
||||||
|
_sync_executor.submit(sync_person_to_json, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def save_db_prompt(prompt_type: str, prompt_text: str, metadata: dict = None):
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO prompt (type, prompt_text, metadata, created_at)
|
||||||
|
VALUES (%s, %s, %s, NOW())
|
||||||
|
ON CONFLICT (type, prompt_text) DO UPDATE
|
||||||
|
SET metadata = EXCLUDED.metadata,
|
||||||
|
created_at = NOW()
|
||||||
|
""", (prompt_type, prompt_text, json.dumps(metadata) if metadata is not None else None))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[db] error saving prompt {prompt_type}: {e}")
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
_put_db_connection(conn)
|
||||||
|
|
||||||
|
def list_db_prompts(prompt_type: str = None, limit: int = 100):
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
if prompt_type:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, type, prompt_text, metadata, created_at
|
||||||
|
FROM prompt
|
||||||
|
WHERE type = %s
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (prompt_type, limit))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, type, prompt_text, metadata, created_at
|
||||||
|
FROM prompt
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cur.fetchall()
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
meta = r[3]
|
||||||
|
if isinstance(meta, str):
|
||||||
|
try:
|
||||||
|
meta = json.loads(meta)
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
elif meta is None:
|
||||||
|
meta = {}
|
||||||
|
result.append({
|
||||||
|
"id": r[0],
|
||||||
|
"type": r[1],
|
||||||
|
"prompt_text": r[2],
|
||||||
|
"metadata": meta,
|
||||||
|
"created_at": r[4].isoformat() if r[4] else None
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[db] error listing prompts: {e}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
_put_db_connection(conn)
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ WORKFLOW_PATH = os.environ.get(
|
|||||||
"WORKFLOW_PATH",
|
"WORKFLOW_PATH",
|
||||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"),
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"),
|
||||||
)
|
)
|
||||||
# Default target pixel area for the output latent. The MI50 is not fast, so we
|
# Default target pixel area for the output latent.
|
||||||
# cap at ~1MP by default; raise via MAX_AREA env if you want bigger output.
|
# We currently cap at ~1MP by default; raise via MAX_AREA env if you want bigger output.
|
||||||
MAX_AREA = int(os.environ.get("MAX_AREA", str(1024 * 1024)))
|
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
|
||||||
|
# This comfortably allows full-HD-ish outputs like 1920x1080.
|
||||||
|
# Override via MAX_AREA when needed.
|
||||||
|
#export MAX_AREA="${MAX_AREA:-2097152}"
|
||||||
|
MAX_AREA = int(os.environ.get("MAX_AREA", str(2097152)))
|
||||||
GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request
|
GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request
|
||||||
|
|
||||||
# Node ids in workflow_qwen_edit.json (kept stable on purpose).
|
# Node ids in workflow_qwen_edit.json (kept stable on purpose).
|
||||||
@@ -1758,6 +1762,23 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
|||||||
if jobs[job_id].get("cancelled"):
|
if jobs[job_id].get("cancelled"):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
database.save_db_prompt("pose-prompt", prompt, {
|
||||||
|
"pose": pose,
|
||||||
|
"seed": seed,
|
||||||
|
"max_area": max_area,
|
||||||
|
"wireframe_ref": wireframe_ref,
|
||||||
|
"wireframe_time": wireframe_time,
|
||||||
|
"pad_top": pad_top,
|
||||||
|
"pad_right": pad_right,
|
||||||
|
"pad_bottom": pad_bottom,
|
||||||
|
"pad_left": pad_left,
|
||||||
|
"pad_fill": pad_fill,
|
||||||
|
"pad_outpaint": pad_outpaint
|
||||||
|
})
|
||||||
|
except Exception as db_err:
|
||||||
|
print(f"[batch] failed to save to prompt table: {db_err}")
|
||||||
|
|
||||||
pil = base_pil
|
pil = base_pil
|
||||||
actual_prompt = prompt
|
actual_prompt = prompt
|
||||||
if pad_outpaint:
|
if pad_outpaint:
|
||||||
@@ -1865,6 +1886,21 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
|
|||||||
|
|
||||||
for prompt, pose in zip(prompts, poses):
|
for prompt, pose in zip(prompts, poses):
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
database.save_db_prompt("pose-prompt", prompt, {
|
||||||
|
"pose": pose,
|
||||||
|
"seed": seed,
|
||||||
|
"max_area": max_area,
|
||||||
|
"filenames": filenames,
|
||||||
|
"pad_top": pad_top,
|
||||||
|
"pad_right": pad_right,
|
||||||
|
"pad_bottom": pad_bottom,
|
||||||
|
"pad_left": pad_left,
|
||||||
|
"pad_fill": pad_fill,
|
||||||
|
"pad_outpaint": pad_outpaint
|
||||||
|
})
|
||||||
|
except Exception as db_err:
|
||||||
|
print(f"[multi-ref] failed to save to prompt table: {db_err}")
|
||||||
work_pil = primary_pil
|
work_pil = primary_pil
|
||||||
actual_prompt = prompt
|
actual_prompt = prompt
|
||||||
if pad_outpaint:
|
if pad_outpaint:
|
||||||
@@ -1947,6 +1983,23 @@ def update_config(update: ConfigUpdate):
|
|||||||
return {"seed": conf["seed"]}
|
return {"seed": conf["seed"]}
|
||||||
|
|
||||||
|
|
||||||
|
class SavePromptRequest(BaseModel):
|
||||||
|
type: str
|
||||||
|
prompt_text: str
|
||||||
|
metadata: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/prompts")
|
||||||
|
def api_save_prompt(req: SavePromptRequest):
|
||||||
|
database.save_db_prompt(req.type, req.prompt_text, req.metadata)
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/prompts")
|
||||||
|
def api_list_prompts(type: str | None = None, limit: int = 100):
|
||||||
|
return database.list_db_prompts(type, limit)
|
||||||
|
|
||||||
|
|
||||||
class GroupArchiveRequest(BaseModel):
|
class GroupArchiveRequest(BaseModel):
|
||||||
filenames: list[str]
|
filenames: list[str]
|
||||||
|
|
||||||
@@ -2123,12 +2176,173 @@ def refine_prompt(req: RefineRequest):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
refined = data["choices"][0]["message"]["content"].strip()
|
refined = data["choices"][0]["message"]["content"].strip()
|
||||||
|
try:
|
||||||
|
database.save_db_prompt("refine", refined, {
|
||||||
|
"original": req.prompt,
|
||||||
|
"filename": req.filename
|
||||||
|
})
|
||||||
|
except Exception as db_err:
|
||||||
|
print(f"[refine-prompt] failed to save to prompt table: {db_err}")
|
||||||
return {"refined": refined}
|
return {"refined": refined}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Refinement error: {e}")
|
print(f"Refinement error: {e}")
|
||||||
raise HTTPException(500, f"LLM refinement failed: {str(e)}")
|
raise HTTPException(500, f"LLM refinement failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePromptRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/images/{filename:path}/reverse-engineer")
|
||||||
|
def reverse_engineer(filename: str):
|
||||||
|
person = database.get_person(filename)
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(404, "Image not found in database")
|
||||||
|
|
||||||
|
# Extract metadata on the fly if pose_description is not present
|
||||||
|
if person[15] is None:
|
||||||
|
try:
|
||||||
|
metadata = _process_image_for_metadata(filename)
|
||||||
|
if metadata:
|
||||||
|
# Reload person row
|
||||||
|
person = database.get_person(filename)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to process image for metadata during reverse-engineer: {e}")
|
||||||
|
|
||||||
|
# Build context string
|
||||||
|
tags_val = person[2]
|
||||||
|
clip_desc_val = person[4]
|
||||||
|
original_prompt = person[6]
|
||||||
|
pose_desc = person[15]
|
||||||
|
people_count = person[17]
|
||||||
|
anatomical_completeness = person[18]
|
||||||
|
facial_direction = person[19]
|
||||||
|
objects_val = person[20]
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# 1. Base prompt or tags
|
||||||
|
if original_prompt:
|
||||||
|
context_parts.append(f"Original Prompt/Tags: {original_prompt}")
|
||||||
|
elif clip_desc_val:
|
||||||
|
context_parts.append(f"Scene Tags Description: {clip_desc_val}")
|
||||||
|
|
||||||
|
# 2. WD Tagger tags
|
||||||
|
if tags_val:
|
||||||
|
try:
|
||||||
|
if isinstance(tags_val, str):
|
||||||
|
tags_list = json.loads(tags_val)
|
||||||
|
else:
|
||||||
|
tags_list = tags_val
|
||||||
|
if tags_list:
|
||||||
|
tag_names = [t["tag"] for t in tags_list if isinstance(t, dict) and "tag" in t and t.get("score", 0) > 0.35]
|
||||||
|
if tag_names:
|
||||||
|
context_parts.append(f"WD Tagger tags: {', '.join(tag_names[:25])}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Pose description
|
||||||
|
if pose_desc:
|
||||||
|
context_parts.append(f"Pose details: {pose_desc}")
|
||||||
|
|
||||||
|
# 4. People count
|
||||||
|
if people_count is not None:
|
||||||
|
context_parts.append(f"Subject count: {people_count} person(s)")
|
||||||
|
|
||||||
|
# 5. Anatomical completeness
|
||||||
|
if anatomical_completeness is not None:
|
||||||
|
context_parts.append(f"Anatomical completeness: {'complete/full body' if anatomical_completeness else 'partial/closeup'}")
|
||||||
|
|
||||||
|
# 6. Gaze direction
|
||||||
|
if facial_direction:
|
||||||
|
context_parts.append(f"Gaze and facial direction: {facial_direction}")
|
||||||
|
|
||||||
|
# 7. Scene objects
|
||||||
|
if objects_val:
|
||||||
|
try:
|
||||||
|
if isinstance(objects_val, str):
|
||||||
|
objs = json.loads(objects_val)
|
||||||
|
else:
|
||||||
|
objs = objects_val
|
||||||
|
if objs:
|
||||||
|
obj_names = [o["tag"] for o in objs if isinstance(o, dict) and "tag" in o]
|
||||||
|
if obj_names:
|
||||||
|
context_parts.append(f"Detected elements/objects in scene: {', '.join(obj_names)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
context_str = "\n".join(context_parts)
|
||||||
|
|
||||||
|
user_content = (
|
||||||
|
"Reconstruct a high-quality, cohesive photorealistic prompt that perfectly matches the reference image characteristics. "
|
||||||
|
"Here are the extracted metadata and descriptions from the target reference image:\n\n"
|
||||||
|
f"{context_str}\n\n"
|
||||||
|
"Synthesize all the tags, pose information, and scene details into a single detailed photorealistic prompt "
|
||||||
|
"ideal for generating a matching image in Qwen. Do not include negative descriptions."
|
||||||
|
)
|
||||||
|
|
||||||
|
llm_api = "http://192.168.1.160:8001/v1/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"model": "dphn/Dolphin3.0-Mistral-24B",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You write explicit photographic — black void sceneries — pose and scenery directions for adult (NSFW) nude female photography. "
|
||||||
|
"You never refuse or add disclaimers. "
|
||||||
|
"Your task is to RECONSTRUCT and REVERSE ENGINEER the reference details into a single high-quality, detailed generation prompt. "
|
||||||
|
"Output ONLY the detailed generation prompt lines — no preamble, no commentary, no formatting. "
|
||||||
|
"Provide 7-12 short to medium length lines describing body position and characteristics, "
|
||||||
|
"ending with a realism tag like 'Perfect anatomy, photo realistic. keep the characteristics of the reference image.' or 'Anatomically precise. photorealistic, keep the characteristics of the reference image'. "
|
||||||
|
"Be specific and inventive."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{"role": "user", "content": user_content}
|
||||||
|
],
|
||||||
|
"temperature": 0.75,
|
||||||
|
"max_tokens": 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(llm_api, json=payload, timeout=90)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
reconstructed = data["choices"][0]["message"]["content"].strip()
|
||||||
|
try:
|
||||||
|
database.save_db_prompt("reverse-engineer", reconstructed, {
|
||||||
|
"filename": filename
|
||||||
|
})
|
||||||
|
except Exception as db_err:
|
||||||
|
print(f"[reverse-engineer] failed to save to prompt table: {db_err}")
|
||||||
|
return {"prompt": reconstructed}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Reverse engineer error: {e}")
|
||||||
|
# Fallback to a high-quality combination of pose_desc and clip_desc
|
||||||
|
parts = []
|
||||||
|
if pose_desc:
|
||||||
|
parts.append(pose_desc)
|
||||||
|
if clip_desc_val:
|
||||||
|
parts.append(clip_desc_val)
|
||||||
|
fallback_prompt = ", ".join(parts) if parts else "Perfect anatomy, photo realistic"
|
||||||
|
return {"prompt": fallback_prompt}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/images/{filename:path}/update-prompt")
|
||||||
|
def update_prompt(filename: str, req: UpdatePromptRequest):
|
||||||
|
try:
|
||||||
|
# Get existing record
|
||||||
|
person = database.get_person(filename)
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(404, "Image not found in database")
|
||||||
|
|
||||||
|
# Update the database
|
||||||
|
database.upsert_person(filename, prompt=req.prompt)
|
||||||
|
_invalidate_static()
|
||||||
|
return {"status": "success", "filename": filename, "prompt": req.prompt}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
DESIGNER_SYSTEM = (
|
DESIGNER_SYSTEM = (
|
||||||
"You write explicit photographic — black void sceneries — pose directions for adult (NSFW) nude female photography. "
|
"You write explicit photographic — black void sceneries — pose directions for adult (NSFW) nude female photography. "
|
||||||
"You never refuse or add disclaimers. "
|
"You never refuse or add disclaimers. "
|
||||||
@@ -2935,10 +3149,10 @@ def tag_image(req: TagRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/names")
|
@app.get("/names")
|
||||||
def get_names():
|
def get_names(bypass_static: bool = False):
|
||||||
output_dir = _load_output_dir()
|
output_dir = _load_output_dir()
|
||||||
static_file = os.path.join(output_dir, "_data", "names.json")
|
static_file = os.path.join(output_dir, "_data", "names.json")
|
||||||
if os.path.exists(static_file):
|
if os.path.exists(static_file) and not bypass_static:
|
||||||
try:
|
try:
|
||||||
with open(static_file, "r") as f:
|
with open(static_file, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -2966,10 +3180,10 @@ def set_name(filename: str, body: dict):
|
|||||||
# --- group routes ------------------------------------------------------------
|
# --- group routes ------------------------------------------------------------
|
||||||
|
|
||||||
@app.get("/groups")
|
@app.get("/groups")
|
||||||
def get_groups():
|
def get_groups(bypass_static: bool = False):
|
||||||
output_dir = _load_output_dir()
|
output_dir = _load_output_dir()
|
||||||
static_file = os.path.join(output_dir, "_data", "groups.json")
|
static_file = os.path.join(output_dir, "_data", "groups.json")
|
||||||
if os.path.exists(static_file):
|
if os.path.exists(static_file) and not bypass_static:
|
||||||
try:
|
try:
|
||||||
with open(static_file, "r") as f:
|
with open(static_file, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -3019,10 +3233,10 @@ def extract_from_group(req: ExtractRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/group-names")
|
@app.get("/group-names")
|
||||||
def get_group_names():
|
def get_group_names(bypass_static: bool = False):
|
||||||
output_dir = _load_output_dir()
|
output_dir = _load_output_dir()
|
||||||
static_file = os.path.join(output_dir, "_data", "group-names.json")
|
static_file = os.path.join(output_dir, "_data", "group-names.json")
|
||||||
if os.path.exists(static_file):
|
if os.path.exists(static_file) and not bypass_static:
|
||||||
try:
|
try:
|
||||||
with open(static_file, "r") as f:
|
with open(static_file, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -4253,6 +4467,17 @@ def generate_scenery(req: SceneryRequest):
|
|||||||
+ "Output a single photorealistic image. High quality, detailed."
|
+ "Output a single photorealistic image. High quality, detailed."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
database.save_db_prompt("scene", prompt, {
|
||||||
|
"model_filename": req.model_filename,
|
||||||
|
"scene_video": req.scene_video,
|
||||||
|
"scene_image": req.scene_image,
|
||||||
|
"extra_filename": req.extra_filename,
|
||||||
|
"seed": req.seed
|
||||||
|
})
|
||||||
|
except Exception as db_err:
|
||||||
|
print(f"[scenery] failed to save prompt: {db_err}")
|
||||||
|
|
||||||
job_id = uuid.uuid4().hex[:8]
|
job_id = uuid.uuid4().hex[:8]
|
||||||
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
|
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
|
|||||||
@@ -3045,13 +3045,13 @@ Eyes looking at camera, keeping your facial characteristics as reference photo.
|
|||||||
One bar, two bands, one lockplate — access spread open and simultaneously sealed.
|
One bar, two bands, one lockplate — access spread open and simultaneously sealed.
|
||||||
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
|
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
|
||||||
|
|
||||||
# Tampon — Internal Plug, External Shield, Integral Collar
|
# Tampon — Internal Plug, Integral Collar
|
||||||
|
|
||||||
You are standing in black void.
|
You are standing in black void.
|
||||||
A single solid steel device, polished and seamless.
|
A single solid steel device, polished and seamless.
|
||||||
An internal plug, contoured and smooth, connected by a short rigid bar to a small external shield covering your vulva.
|
An internal plug, contoured and smooth, connected by a short rigid bar to a small external shield covering your vulva.
|
||||||
From the shield, a thin rigid steel bar rises up your abdomen and sternum, terminating in a collar locked around your neck.
|
From the shield, a thin rigid steel bar rises up your abdomen and sternum, terminating in a collar locked around your neck.
|
||||||
One continuous piece — internal plug, external shield, connecting bar, collar.
|
One continuous piece — internal plug, connecting bar, collar.
|
||||||
Your wrists cuffed to the vertical bar at your hips.
|
Your wrists cuffed to the vertical bar at your hips.
|
||||||
Your ankles cuffed together.
|
Your ankles cuffed together.
|
||||||
The device occupies and seals simultaneously — internal and external denial in one form.
|
The device occupies and seals simultaneously — internal and external denial in one form.
|
||||||
|
|||||||
114
tour-comfy/test_regression_qwen.py
Normal file
114
tour-comfy/test_regression_qwen.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Ensure tour-comfy is in the import path
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if _HERE not in sys.path:
|
||||||
|
sys.path.append(_HERE)
|
||||||
|
|
||||||
|
from edit_api import app, _load_output_dir
|
||||||
|
|
||||||
|
class TestQwenBackendRegression(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.client = TestClient(app)
|
||||||
|
cls.output_dir = _load_output_dir()
|
||||||
|
|
||||||
|
# Real-data filenames specified in the issue description
|
||||||
|
cls.real_img_1 = "20260618_053519_1_20260618_053458_image.png"
|
||||||
|
cls.real_img_2 = "20260618_052537_0_20260618_052526_image.png"
|
||||||
|
cls.wireframe_img = "up_20260628_104641_image.png"
|
||||||
|
|
||||||
|
# Validate that they exist on disk to run real end-to-end tests
|
||||||
|
cls.run_real_tests = True
|
||||||
|
img1_path = os.path.join(cls.output_dir, cls.real_img_1)
|
||||||
|
img2_path = os.path.join(cls.output_dir, cls.real_img_2)
|
||||||
|
|
||||||
|
if not os.path.exists(img1_path) or not os.path.exists(img2_path):
|
||||||
|
cls.run_real_tests = False
|
||||||
|
print(f"[test] Real images not found at {img1_path} or {img2_path}. Running tests with fallback/mock assertion modes.")
|
||||||
|
|
||||||
|
def test_multi_ref_qwen_endpoint(self):
|
||||||
|
"""Test multi-reference generation endpoint with specified real-data images."""
|
||||||
|
if not self.run_real_tests:
|
||||||
|
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"filenames": [self.real_img_1, self.real_img_2],
|
||||||
|
"prompt": "standing girl looking at camera, high quality, highly detailed",
|
||||||
|
"seed": 42,
|
||||||
|
"max_area": 512 * 512,
|
||||||
|
"pad_outpaint": False
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post("/multi-ref", json=payload)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("job_id", data)
|
||||||
|
job_id = data["job_id"]
|
||||||
|
|
||||||
|
# Poll status until done (limit to 300 seconds to prevent hanging if backend is offline)
|
||||||
|
print(f"[test] Polling multi-ref job {job_id} until completion...")
|
||||||
|
start_time = time.time()
|
||||||
|
completed = False
|
||||||
|
|
||||||
|
while time.time() - start_time < 300:
|
||||||
|
status_resp = self.client.get(f"/batch/{job_id}")
|
||||||
|
if status_resp.status_code == 200:
|
||||||
|
job_data = status_resp.json()
|
||||||
|
status = job_data.get("status")
|
||||||
|
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
|
||||||
|
if status == "done":
|
||||||
|
completed = True
|
||||||
|
break
|
||||||
|
elif status in ["error", "cancelled"]:
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
self.assertTrue(completed, "Multi-ref generation job did not complete successfully in time.")
|
||||||
|
|
||||||
|
def test_scenery_qwen_endpoint(self):
|
||||||
|
"""Test scenery generation endpoint with specified wireframe scene image and prompt."""
|
||||||
|
if not self.run_real_tests:
|
||||||
|
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model_filename": self.real_img_1,
|
||||||
|
"scene_image": self.wireframe_img,
|
||||||
|
"prompt": "replace person from image 1 naturally with the person from Image 2. Keep the exact position of Image 1, and the exact person of Image 2",
|
||||||
|
"seed": 42
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post("/generate-scenery", json=payload)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("job_id", data)
|
||||||
|
job_id = data["job_id"]
|
||||||
|
|
||||||
|
# Poll status until done
|
||||||
|
print(f"[test] Polling scenery job {job_id} until completion...")
|
||||||
|
start_time = time.time()
|
||||||
|
completed = False
|
||||||
|
|
||||||
|
while time.time() - start_time < 300:
|
||||||
|
status_resp = self.client.get(f"/batch/{job_id}")
|
||||||
|
if status_resp.status_code == 200:
|
||||||
|
job_data = status_resp.json()
|
||||||
|
status = job_data.get("status")
|
||||||
|
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
|
||||||
|
if status == "done":
|
||||||
|
completed = True
|
||||||
|
break
|
||||||
|
elif status in ["error", "cancelled"]:
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
self.assertTrue(completed, "Scenery generation job did not complete successfully in time.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user