Summary
• Implemented robust model and scene reference validations in both the FastAPI backend and frontend to prevent empty, corrupt, or missing files from silently failing or producing duplicated background fallbacks. • Enhanced scenery prompt translation on the backend to dynamically map user-specified "Image" numbers e.g. Image 1, Image 2 to Qwen-specific "Picture" placeholders Picture 1, Picture 2, avoiding template mismatch desyncs. Changes • File & Path Validation: Added detailed validations to the /generate-scenery endpoint to ensure model_filename and scene_image paths exist on disk, are resolved without query parameters stripping ?t=..., are not directories, and are openable and verifiable as PIL images. • Identical Reference Checks: Introduced checks in both the backend and frontend submitGenerateScenery inside car.html to raise an HTTP 400 exception / display a toast warning if the model and scene references point to the same image. • Dynamic Prompt Mapping: Configured the backend to auto-translate occurrences of image 1/2/3 in custom scenery prompts to Picture 1/2/3, facilitating correct Qwen layout hint targeting. • Regression Unit Tests: Wrote comprehensive unit tests test_13b_scenery_validation_checks in test_regression_api.py verifying file checking, duplicate reference detection, and query parameter stripping. Verification • Executed test_regression_api.py simulated API testing with 100% success 19/19 tests passed. • Executed test_regression_qwen.py end-to-end model/hardware integration test suite successfully 2/2 tests passed.
This commit is contained in:
@@ -2556,34 +2556,66 @@
|
||||
|
||||
// --- HYDRATION_START ---
|
||||
const PRELOADED_IMAGES = [
|
||||
"20260701_064213_sc_pad_view_000_000deg.png",
|
||||
"20260701_113041_sc_3_20260628_205049_image.png",
|
||||
"20260701_112945_sc_3_20260628_205049_image.png",
|
||||
"20260701_112713_sc_3_20260628_205049_image.png",
|
||||
"20260701_112118_sc_image.nobg.png",
|
||||
"20260701_111927_sc_image.nobg.png",
|
||||
"20260701_111515_sc_image.nobg.png",
|
||||
"20260701_111415_sc_image.png",
|
||||
"20260701_111333_image.png",
|
||||
"20260701_111102_sc_image.png",
|
||||
"20260701_111027_image.png",
|
||||
"20260701_110958_test_upload_image.png",
|
||||
"20260701_110930_test_upload_image.png",
|
||||
"20260701_110842_test_upload_image.png",
|
||||
"20260701_110730_image.png",
|
||||
"20260701_110604_sc_image.png",
|
||||
"20260701_110400_sc_image.png",
|
||||
"20260701_105852_sc_image.nobg.png",
|
||||
"20260701_105307_sc_image.nobg.nobg.png",
|
||||
"20260701_105019_sc_image.nobg.nobg.png",
|
||||
"20260701_105003_sc_image.nobg.png",
|
||||
"20260701_104901_sc_image.nobg.png",
|
||||
"20260701_064734_sc_image.nobg.nobg.png",
|
||||
"20260701_104623_sc_image.nobg.png",
|
||||
"20260701_104606_sc_image.nobg.png",
|
||||
"20260701_104550_sc_image.nobg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_023_345deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_022_330deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_021_315deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_020_300deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_019_285deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_018_270deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_017_255deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_016_240deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_015_225deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_014_210deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_013_195deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_012_180deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_011_165deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_010_150deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_009_135deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_008_120deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_007_105deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_006_090deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_005_075deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_004_060deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_003_045deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_002_030deg.png",
|
||||
"20260701_065556_sc_3_20260628_205049_image.nobg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_001_015deg.png",
|
||||
"_turntable/cg_9aadaf6d/views/view_000_000deg.png",
|
||||
"20260701_064913_sc_1_20260618_053458_image.png",
|
||||
"20260701_064840_mr_1_20260618_053458_image.png",
|
||||
"20260701_064828_sc_image.png",
|
||||
"20260701_064812_test_upload_image.png",
|
||||
"20260701_064734_test_upload_image.png",
|
||||
"20260701_064603_sc_image.png",
|
||||
"20260701_064521_sc_image.png",
|
||||
"20260701_064420_sc_image.png",
|
||||
"20260701_063422_sc_2_20260618_052526_image.nobg.nobg.png",
|
||||
"20260701_062850_sc_pad_view_000_000deg.png",
|
||||
"20260701_062218_pad_view_000_000deg.png",
|
||||
"20260701_062201_pad_view_000_000deg.png",
|
||||
"20260701_062144_pad_view_000_000deg.png",
|
||||
"20260701_062128_pad_view_000_000deg.png",
|
||||
"20260701_062111_pad_view_000_000deg.png",
|
||||
"20260701_062054_pad_view_000_000deg.png",
|
||||
"20260701_062037_pad_view_000_000deg.png",
|
||||
"20260701_061913_pad_view_000_000deg.png",
|
||||
"20260701_061854_pad_view_000_000deg.png",
|
||||
"20260701_061837_pad_view_000_000deg.png",
|
||||
"20260701_061814_pad_view_000_000deg.png",
|
||||
"20260701_060854_pad_view_000_000deg.png",
|
||||
"20260701_060215_pad_view_000_000deg.png",
|
||||
"20260701_060110_pad_view_000_000deg.png",
|
||||
"20260701_060054_pad_view_000_000deg.png",
|
||||
"20260701_055944_pad_view_000_000deg.png",
|
||||
"20260701_055727_pad_view_000_000deg.png",
|
||||
"20260701_055710_pad_view_000_000deg.png",
|
||||
"20260701_055654_pad_view_000_000deg.png",
|
||||
"20260701_055637_pad_view_000_000deg.png",
|
||||
"20260701_055621_pad_view_000_000deg.png",
|
||||
"20260701_055605_pad_view_000_000deg.png",
|
||||
"20260701_055542_pad_view_000_000deg.png",
|
||||
"up_f475c600_face.png",
|
||||
"20260701_055451_pad_view_000_000deg.png",
|
||||
"20260701_051754_image.png",
|
||||
"20260701_051737_image.png",
|
||||
"20260701_051721_image.png",
|
||||
@@ -3265,7 +3297,6 @@
|
||||
"20260628_205348_9_20260628_205049_image.png",
|
||||
"20260628_205336_8_20260628_205049_image.png",
|
||||
"20260628_205325_7_20260628_205049_image.png",
|
||||
"20260628_205302_5_20260628_205049_image.png",
|
||||
"20260628_205240_3_20260628_205049_image.png",
|
||||
"20260628_205229_2_20260628_205049_image.png",
|
||||
"20260628_205217_1_20260628_205049_image.png",
|
||||
@@ -16035,6 +16066,30 @@
|
||||
if (!bg) { showToast('Set a background (Image 1) — capture, upload, or Shift+Click', 'error'); return; }
|
||||
if (!person || person.kind !== 'file') { showToast('Set a person image (Image 2) from the filmstrip', 'error'); return; }
|
||||
if (extra && extra.kind !== 'file') { showToast('Image 3 must be a gallery image', 'error'); return; }
|
||||
|
||||
// Validate distinct files
|
||||
if (bg.kind === 'file' && person.kind === 'file' && bg.name === person.name) {
|
||||
showToast('Image 1 (scenery) and Image 2 (person) cannot be the same image.', 'error');
|
||||
return;
|
||||
}
|
||||
if (bg.kind === 'wireframe' && person.kind === 'file' && bg.name === person.name) {
|
||||
showToast('Image 1 (scenery) and Image 2 (person) cannot be the same image.', 'error');
|
||||
return;
|
||||
}
|
||||
if (extra && extra.kind === 'file') {
|
||||
if (person.kind === 'file' && extra.name === person.name) {
|
||||
showToast('Image 3 (extra reference) and Image 2 (person) cannot be the same image.', 'error');
|
||||
return;
|
||||
}
|
||||
if (bg.kind === 'file' && extra.name === bg.name) {
|
||||
showToast('Image 3 (extra reference) and Image 1 (scenery) cannot be the same image.', 'error');
|
||||
return;
|
||||
}
|
||||
if (bg.kind === 'wireframe' && extra.name === bg.name) {
|
||||
showToast('Image 3 (extra reference) and Image 1 (scenery) cannot be the same image.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const prompt = (document.getElementById('scenePromptInput')?.value || '').trim() || null;
|
||||
const btn = document.getElementById('sceneGenBtn');
|
||||
const statusEl = document.getElementById('sceneJobStatus');
|
||||
@@ -16051,11 +16106,23 @@
|
||||
let sceneImage = null;
|
||||
if (bg.kind === 'bytes') {
|
||||
sceneBytes = bg.data;
|
||||
if (bg.filename) {
|
||||
sceneImage = bg.filename;
|
||||
}
|
||||
} else if (bg.kind === 'wireframe') {
|
||||
sceneImage = bg.name;
|
||||
} else if (bg.kind === 'file') {
|
||||
sceneImage = bg.name;
|
||||
try {
|
||||
sceneBytes = await _fetchAsBase64(_sceneUrlForName(bg.name));
|
||||
} catch (e) {
|
||||
showToast('Could not read background image: ' + e, 'error');
|
||||
if (btn) btn.disabled = false; if (statusEl) statusEl.style.display = 'none'; return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
sceneBytes = await _fetchAsBase64(_sceneUrlForName(bg.name));
|
||||
sceneImage = bg.name;
|
||||
} catch (e) {
|
||||
showToast('Could not read background image: ' + e, 'error');
|
||||
if (btn) btn.disabled = false; if (statusEl) statusEl.style.display = 'none'; return;
|
||||
|
||||
@@ -4512,7 +4512,24 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
|
||||
scene_video: str | None = None, scene_image: str | None = None,
|
||||
extra_filename: str | None = None):
|
||||
output_dir = _load_output_dir()
|
||||
out_path = None
|
||||
try:
|
||||
# Construct and validate source references early
|
||||
refs = [model_filename]
|
||||
if scene_video:
|
||||
refs.append(f"video:{scene_video}")
|
||||
if scene_image:
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
if os.path.exists(os.path.join(wireframe_dir, scene_image)) or not os.path.exists(os.path.join(output_dir, scene_image)):
|
||||
refs.append(f"wireframe:{scene_image}")
|
||||
else:
|
||||
refs.append(scene_image)
|
||||
if extra_filename:
|
||||
refs.append(extra_filename)
|
||||
|
||||
if len(refs) < 2:
|
||||
raise ValueError(f"Scenery generation failed: expected at least 2 source references (model and scene), but got {len(refs)}: {refs}")
|
||||
|
||||
model_path = os.path.join(output_dir, model_filename)
|
||||
model_pil = Image.open(model_path).convert("RGB")
|
||||
|
||||
@@ -4540,17 +4557,10 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
|
||||
f.write(png_bytes)
|
||||
person = database.get_person(model_filename)
|
||||
group_id = person[1] if person and person[1] else naming.get_base_name(os.path.basename(model_filename))
|
||||
|
||||
try:
|
||||
embedding = embeddings.generate_embedding(out_path)
|
||||
next_order = database.get_next_sort_order(group_id)
|
||||
# Store all source references: person image, background video (if any), extra ref (if any)
|
||||
refs = [model_filename]
|
||||
if scene_video:
|
||||
refs.append(f"video:{scene_video}")
|
||||
if scene_image:
|
||||
refs.append(f"wireframe:{scene_image}")
|
||||
if extra_filename:
|
||||
refs.append(extra_filename)
|
||||
database.upsert_person(out_name, filepath=out_path, embedding=embedding,
|
||||
group_id=group_id, prompt=prompt,
|
||||
sort_order=next_order,
|
||||
@@ -4559,6 +4569,8 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
|
||||
_metadata_executor.submit(_process_image_for_metadata, out_name)
|
||||
except Exception as db_err:
|
||||
print(f"[scenery] DB error: {db_err}")
|
||||
raise
|
||||
|
||||
jobs[job_id]["latest_output"] = out_name
|
||||
jobs[job_id]["status"] = "done"
|
||||
jobs[job_id]["output"] = out_name
|
||||
@@ -4570,6 +4582,12 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
|
||||
print(f"[scenery] error: {e}")
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
if out_path and os.path.exists(out_path):
|
||||
try:
|
||||
os.remove(out_path)
|
||||
print(f"[scenery] Cleaned up unregistered file due to error: {out_path}")
|
||||
except Exception as clean_err:
|
||||
print(f"[scenery] Clean up error: {clean_err}")
|
||||
|
||||
|
||||
@app.post("/generate-scenery")
|
||||
@@ -4577,24 +4595,71 @@ def generate_scenery(req: SceneryRequest):
|
||||
output_dir = _load_output_dir()
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
|
||||
model_path = os.path.join(output_dir, req.model_filename)
|
||||
if not req.model_filename:
|
||||
raise HTTPException(400, "Model image (Image 2) is required and cannot be empty.")
|
||||
|
||||
# Strip query parameters (e.g. ?t=...)
|
||||
clean_model_filename = req.model_filename.split('?')[0].strip()
|
||||
if not clean_model_filename:
|
||||
raise HTTPException(400, "Model image filename is empty after cleaning.")
|
||||
|
||||
model_path = os.path.join(output_dir, clean_model_filename)
|
||||
if not os.path.exists(model_path):
|
||||
raise HTTPException(404, f"Model image not found: {req.model_filename}")
|
||||
raise HTTPException(404, f"Model image not found: {clean_model_filename}")
|
||||
if not os.path.isfile(model_path):
|
||||
raise HTTPException(400, f"Model path is not a file: {clean_model_filename}")
|
||||
|
||||
# Validate that it is a valid, openable image file
|
||||
try:
|
||||
with Image.open(model_path) as img:
|
||||
img.verify()
|
||||
except Exception as img_err:
|
||||
raise HTTPException(400, f"Invalid or corrupt model image: {img_err}")
|
||||
|
||||
# Resolve scene image
|
||||
clean_scene_image = req.scene_image.split('?')[0].strip() if req.scene_image else None
|
||||
|
||||
# Enforce that we have at least one background source reference (either scene_image or scene_video)
|
||||
if not clean_scene_image and not req.scene_video:
|
||||
raise HTTPException(400, "Scenery generation requires a background scene reference (either scene_image or scene_video).")
|
||||
|
||||
# Check if model image and scene image are identical files
|
||||
if clean_scene_image and clean_scene_image == clean_model_filename:
|
||||
raise HTTPException(400, "Image 1 (scenery) and Image 2 (person) cannot be the same image.")
|
||||
|
||||
# Verify background reference existence on disk early if clean_scene_image is provided
|
||||
if clean_scene_image:
|
||||
image_path = os.path.join(wireframe_dir, clean_scene_image)
|
||||
if not os.path.exists(image_path):
|
||||
image_path = os.path.join(output_dir, clean_scene_image) # Check output_dir as fallback!
|
||||
if not os.path.exists(image_path):
|
||||
raise HTTPException(404, f"Scene image not found: {clean_scene_image}")
|
||||
|
||||
if req.scene_bytes:
|
||||
import base64
|
||||
raw = base64.b64decode(req.scene_bytes)
|
||||
scene_pil = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
elif req.scene_image:
|
||||
image_path = os.path.join(wireframe_dir, req.scene_image)
|
||||
try:
|
||||
raw = base64.b64decode(req.scene_bytes)
|
||||
# Verify image
|
||||
with Image.open(io.BytesIO(raw)) as img:
|
||||
img.verify()
|
||||
scene_pil = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
except Exception as img_err:
|
||||
raise HTTPException(400, f"Invalid or corrupt scene image: {img_err}")
|
||||
elif clean_scene_image:
|
||||
image_path = os.path.join(wireframe_dir, clean_scene_image)
|
||||
if not os.path.exists(image_path):
|
||||
raise HTTPException(404, f"Scene image not found: {req.scene_image}")
|
||||
scene_pil = Image.open(image_path).convert("RGB")
|
||||
image_path = os.path.join(output_dir, clean_scene_image)
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
img.verify()
|
||||
scene_pil = Image.open(image_path).convert("RGB")
|
||||
except Exception as img_err:
|
||||
raise HTTPException(400, f"Invalid or corrupt scene image: {img_err}")
|
||||
elif req.scene_video:
|
||||
video_path = os.path.join(wireframe_dir, req.scene_video)
|
||||
clean_scene_video = req.scene_video.split('?')[0].strip()
|
||||
video_path = os.path.join(wireframe_dir, clean_scene_video)
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(404, f"Scene video not found: {req.scene_video}")
|
||||
raise HTTPException(404, f"Scene video not found: {clean_scene_video}")
|
||||
try:
|
||||
scene_pil = _extract_frame_at(video_path, req.scene_time)
|
||||
except Exception as e:
|
||||
@@ -4604,27 +4669,47 @@ def generate_scenery(req: SceneryRequest):
|
||||
|
||||
# Optional image3 (Picture 3) — extra reference from output_dir
|
||||
extra_pils = []
|
||||
if req.extra_filename:
|
||||
extra_path = os.path.join(output_dir, req.extra_filename)
|
||||
if not os.path.exists(extra_path):
|
||||
raise HTTPException(404, f"Extra reference not found: {req.extra_filename}")
|
||||
extra_pils.append(Image.open(extra_path).convert("RGB"))
|
||||
clean_extra_filename = req.extra_filename.split('?')[0].strip() if req.extra_filename else None
|
||||
if clean_extra_filename:
|
||||
if clean_extra_filename == clean_model_filename:
|
||||
raise HTTPException(400, "Image 3 (extra reference) and Image 2 (person) cannot be the same image.")
|
||||
if clean_scene_image and clean_extra_filename == clean_scene_image:
|
||||
raise HTTPException(400, "Image 3 (extra reference) and Image 1 (scenery) cannot be the same image.")
|
||||
|
||||
prompt = req.prompt or (
|
||||
"Place the person from Picture 2 naturally inside the environment shown in Picture 1. "
|
||||
"Keep the person's face, body proportions, clothing and pose exactly as in Picture 2. "
|
||||
"Use the location, lighting and atmosphere from Picture 1 as the background. "
|
||||
"Match the color temperature and shadows so it looks like one photograph taken on location. "
|
||||
+ ("Incorporate the reference from Picture 3 as well. " if extra_pils else "")
|
||||
+ "Output a single photorealistic image. High quality, detailed."
|
||||
)
|
||||
extra_path = os.path.join(output_dir, clean_extra_filename)
|
||||
if not os.path.exists(extra_path):
|
||||
raise HTTPException(404, f"Extra reference not found: {clean_extra_filename}")
|
||||
if not os.path.isfile(extra_path):
|
||||
raise HTTPException(400, f"Extra reference path is not a file: {clean_extra_filename}")
|
||||
try:
|
||||
with Image.open(extra_path) as img:
|
||||
img.verify()
|
||||
extra_pils.append(Image.open(extra_path).convert("RGB"))
|
||||
except Exception as img_err:
|
||||
raise HTTPException(400, f"Invalid or corrupt extra reference image: {img_err}")
|
||||
|
||||
# Process and refine prompt to map Image 1/2/3 to Picture 1/2/3
|
||||
prompt = req.prompt
|
||||
if prompt:
|
||||
prompt = re.sub(r"(?i)\bimage\s*1\b", "Picture 1", prompt)
|
||||
prompt = re.sub(r"(?i)\bimage\s*2\b", "Picture 2", prompt)
|
||||
prompt = re.sub(r"(?i)\bimage\s*3\b", "Picture 3", prompt)
|
||||
else:
|
||||
prompt = (
|
||||
"Place the person from Picture 2 naturally inside the environment shown in Picture 1. "
|
||||
"Keep the person's face, body proportions, clothing and pose exactly as in Picture 2. "
|
||||
"Use the location, lighting and atmosphere from Picture 1 as the background. "
|
||||
"Match the color temperature and shadows so it looks like one photograph taken on location. "
|
||||
+ ("Incorporate the reference from Picture 3 as well. " if extra_pils else "")
|
||||
+ "Output a single photorealistic image. High quality, detailed."
|
||||
)
|
||||
|
||||
try:
|
||||
database.save_db_prompt("scene", prompt, {
|
||||
"model_filename": req.model_filename,
|
||||
"model_filename": clean_model_filename,
|
||||
"scene_video": req.scene_video,
|
||||
"scene_image": req.scene_image,
|
||||
"extra_filename": req.extra_filename,
|
||||
"scene_image": clean_scene_image,
|
||||
"extra_filename": clean_extra_filename,
|
||||
"seed": req.seed
|
||||
})
|
||||
except Exception as db_err:
|
||||
@@ -4634,13 +4719,13 @@ def generate_scenery(req: SceneryRequest):
|
||||
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
|
||||
threading.Thread(
|
||||
target=_scenery_worker,
|
||||
args=(job_id, req.model_filename, scene_pil, prompt, req.seed, extra_pils),
|
||||
args=(job_id, clean_model_filename, scene_pil, prompt, req.seed, extra_pils),
|
||||
kwargs={"scene_video": req.scene_video if req.scene_video else None,
|
||||
"scene_image": req.scene_image if req.scene_image else None,
|
||||
"extra_filename": req.extra_filename},
|
||||
"scene_image": clean_scene_image if clean_scene_image else None,
|
||||
"extra_filename": clean_extra_filename},
|
||||
daemon=True,
|
||||
).start()
|
||||
return {"job_id": job_id, "model": req.model_filename}
|
||||
return {"job_id": job_id, "model": clean_model_filename}
|
||||
|
||||
|
||||
@app.get("/scenery/library")
|
||||
|
||||
233
tour-comfy/test/test_scenery_case.py
Normal file
233
tour-comfy/test/test_scenery_case.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"]
|
||||
|
||||
|
||||
def slug(value: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", value).strip("_") or "case"
|
||||
|
||||
|
||||
def find_image(folder: Path, stem: str) -> Path | None:
|
||||
for file in sorted(folder.iterdir()):
|
||||
if file.is_file() and file.stem.lower() == stem.lower() and file.suffix.lower() in IMAGE_EXTS:
|
||||
return file
|
||||
return None
|
||||
|
||||
|
||||
def find_cases(root: Path) -> list[dict]:
|
||||
cases = []
|
||||
|
||||
for folder, _, _ in os.walk(root):
|
||||
folder = Path(folder)
|
||||
|
||||
prompt_file = folder / "prompt.md"
|
||||
image1 = find_image(folder, "image1")
|
||||
image2 = find_image(folder, "image2")
|
||||
|
||||
if prompt_file.exists() and image1 and image2:
|
||||
cases.append({
|
||||
"folder": folder,
|
||||
"image1": image1,
|
||||
"image2": image2,
|
||||
"prompt": prompt_file,
|
||||
})
|
||||
|
||||
return cases
|
||||
|
||||
|
||||
def expected_prompt_refinement(prompt: str) -> str:
|
||||
# Match de intent uit je bestaande test:
|
||||
# "image 1" / "Image 2" moet "Picture 1" / "Picture 2" worden.
|
||||
prompt = re.sub(r"\bimage\s+1\b", "Picture 1", prompt, flags=re.IGNORECASE)
|
||||
prompt = re.sub(r"\bimage\s+2\b", "Picture 2", prompt, flags=re.IGNORECASE)
|
||||
return prompt
|
||||
|
||||
|
||||
def run_case(edit_api, HTTPException, case: dict, output_dir: Path, check_refinement: bool) -> tuple[bool, str]:
|
||||
folder = case["folder"]
|
||||
case_name = slug(folder.relative_to(folder.parents[0]).as_posix())
|
||||
|
||||
model_filename = f"{case_name}__image1{case['image1'].suffix.lower()}"
|
||||
scene_filename = f"{case_name}__image2{case['image2'].suffix.lower()}"
|
||||
|
||||
model_path = output_dir / model_filename
|
||||
scene_path = output_dir / scene_filename
|
||||
|
||||
shutil.copy2(case["image1"], model_path)
|
||||
shutil.copy2(case["image2"], scene_path)
|
||||
|
||||
prompt = case["prompt"].read_text(encoding="utf-8").strip()
|
||||
|
||||
saved_prompts = []
|
||||
|
||||
def mock_save_db_prompt(ptype, prompt_text, meta):
|
||||
saved_prompts.append(prompt_text)
|
||||
|
||||
# Test 1: missing model image -> 404
|
||||
req_missing = edit_api.SceneryRequest(
|
||||
model_filename=f"missing_{case_name}.png",
|
||||
scene_image=scene_filename,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
try:
|
||||
edit_api.generate_scenery(req_missing)
|
||||
return False, "Missing-model check faalde: er kwam geen HTTPException."
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
return False, f"Missing-model check gaf status {exc.status_code}, verwacht 404."
|
||||
if "Model image not found" not in str(exc.detail):
|
||||
return False, f"Missing-model check detail onverwacht: {exc.detail}"
|
||||
|
||||
# Test 2: model en scene zijn hetzelfde bestand -> 400
|
||||
req_identical = edit_api.SceneryRequest(
|
||||
model_filename=model_filename,
|
||||
scene_image=model_filename,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
try:
|
||||
edit_api.generate_scenery(req_identical)
|
||||
return False, "Identical-image check faalde: er kwam geen HTTPException."
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 400:
|
||||
return False, f"Identical-image check gaf status {exc.status_code}, verwacht 400."
|
||||
if "cannot be the same image" not in str(exc.detail):
|
||||
return False, f"Identical-image check detail onverwacht: {exc.detail}"
|
||||
|
||||
# Test 3: geldige case, prompt refinement controleren, geen echte thread starten
|
||||
req_valid = edit_api.SceneryRequest(
|
||||
model_filename=model_filename,
|
||||
scene_image=scene_filename,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
with patch("edit_api._load_wireframe_dir", return_value=str(output_dir), create=True), \
|
||||
patch("edit_api.database.save_db_prompt", side_effect=mock_save_db_prompt), \
|
||||
patch("threading.Thread.start"):
|
||||
|
||||
edit_api.generate_scenery(req_valid)
|
||||
|
||||
if len(saved_prompts) != 1:
|
||||
return False, f"save_db_prompt werd {len(saved_prompts)} keer aangeroepen, verwacht 1."
|
||||
|
||||
if check_refinement:
|
||||
expected = expected_prompt_refinement(prompt)
|
||||
actual = saved_prompts[0]
|
||||
|
||||
if actual != expected:
|
||||
return False, (
|
||||
"Prompt refinement klopt niet.\n"
|
||||
f"Expected: {expected!r}\n"
|
||||
f"Actual: {actual!r}"
|
||||
)
|
||||
|
||||
return True, "OK"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--cases-dir",
|
||||
default="scenery_cases",
|
||||
help="Root-folder met subfolders die image1.*, image2.* en prompt.md bevatten.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
help="Optioneel vaste output-dir. Default gebruikt een tijdelijke map.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-refinement-check",
|
||||
action="store_true",
|
||||
help="Alleen controleren dat generate_scenery werkt, niet de exacte prompt-output.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path.cwd()
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import edit_api
|
||||
from fastapi import HTTPException
|
||||
|
||||
cases_root = Path(args.cases_dir).resolve()
|
||||
|
||||
if not cases_root.exists():
|
||||
print(f"ERROR: cases-dir bestaat niet: {cases_root}")
|
||||
return 2
|
||||
|
||||
cases = find_cases(cases_root)
|
||||
|
||||
if not cases:
|
||||
print(f"Geen testcases gevonden onder: {cases_root}")
|
||||
print("Verwachte structuur per testcase:")
|
||||
print(" some_case/")
|
||||
print(" image1.png")
|
||||
print(" image2.png")
|
||||
print(" prompt.md")
|
||||
return 2
|
||||
|
||||
if args.output_dir:
|
||||
output_dir = Path(args.output_dir).resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
cleanup = contextlib.nullcontext(output_dir)
|
||||
else:
|
||||
cleanup = tempfile.TemporaryDirectory(prefix="scenery_validation_")
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
with cleanup as tmp:
|
||||
output_dir = Path(tmp) if not isinstance(tmp, Path) else tmp
|
||||
|
||||
print(f"Cases root : {cases_root}")
|
||||
print(f"Output dir : {output_dir}")
|
||||
print(f"Cases : {len(cases)}")
|
||||
print()
|
||||
|
||||
for case in cases:
|
||||
label = case["folder"].relative_to(cases_root)
|
||||
|
||||
try:
|
||||
ok, message = run_case(
|
||||
edit_api=edit_api,
|
||||
HTTPException=HTTPException,
|
||||
case=case,
|
||||
output_dir=output_dir,
|
||||
check_refinement=not args.no_refinement_check,
|
||||
)
|
||||
|
||||
if ok:
|
||||
passed += 1
|
||||
print(f"[PASS] {label}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f"[FAIL] {label}")
|
||||
print(message)
|
||||
print()
|
||||
|
||||
except Exception:
|
||||
failed += 1
|
||||
print(f"[ERROR] {label}")
|
||||
traceback.print_exc()
|
||||
print()
|
||||
|
||||
print()
|
||||
print(f"Resultaat: {passed} passed, {failed} failed")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -799,6 +799,116 @@ class TestAPIRegression(unittest.TestCase):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_13b_scenery_validation_checks(self):
|
||||
from unittest.mock import patch
|
||||
import edit_api
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Test 1: model image is completely missing -> raise 404
|
||||
req_missing = edit_api.SceneryRequest(
|
||||
model_filename="non_existent_model_file_999.png",
|
||||
scene_image="some_scene.png",
|
||||
prompt="replace person from image 1 with the person from Image 2"
|
||||
)
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
edit_api.generate_scenery(req_missing)
|
||||
self.assertEqual(ctx.exception.status_code, 404)
|
||||
self.assertIn("Model image not found", ctx.exception.detail)
|
||||
|
||||
# Write dummy model image on disk for subsequent tests
|
||||
model_filename = "test_model_validation_123.png"
|
||||
model_path = os.path.join(self.output_dir, model_filename)
|
||||
Image.new("RGB", (10, 10), color="red").save(model_path)
|
||||
|
||||
try:
|
||||
# Test 2: model and scene images are the same file -> raise 400
|
||||
req_identical = edit_api.SceneryRequest(
|
||||
model_filename=model_filename,
|
||||
scene_image=model_filename,
|
||||
prompt="replace person from image 1 with the person from Image 2"
|
||||
)
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
edit_api.generate_scenery(req_identical)
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertIn("cannot be the same image", ctx.exception.detail)
|
||||
|
||||
# Test 3: prompt refinement "image 1/2" -> "Picture 1/2"
|
||||
# We mock the thread start and save_db_prompt to verify the processed prompt
|
||||
saved_prompt = []
|
||||
def mock_save_db_prompt(ptype, prompt_text, meta):
|
||||
saved_prompt.append(prompt_text)
|
||||
|
||||
dummy_scene_filename = "dummy_scene_validation_123.png"
|
||||
dummy_scene_path = os.path.join(self.output_dir, dummy_scene_filename)
|
||||
Image.new("RGB", (10, 10), color="blue").save(dummy_scene_path)
|
||||
|
||||
try:
|
||||
req_refine = edit_api.SceneryRequest(
|
||||
model_filename=model_filename,
|
||||
scene_image=dummy_scene_filename,
|
||||
prompt="replace person from image 1 with the person from Image 2"
|
||||
)
|
||||
|
||||
with patch("edit_api._load_wireframe_dir", return_value=self.output_dir), \
|
||||
patch("edit_api.database.save_db_prompt", side_effect=mock_save_db_prompt), \
|
||||
patch("threading.Thread.start") as mock_thread_start:
|
||||
|
||||
edit_api.generate_scenery(req_refine)
|
||||
|
||||
self.assertEqual(len(saved_prompt), 1)
|
||||
self.assertEqual(saved_prompt[0], "replace person from Picture 1 with the person from Picture 2")
|
||||
|
||||
finally:
|
||||
if os.path.exists(dummy_scene_path):
|
||||
os.remove(dummy_scene_path)
|
||||
|
||||
finally:
|
||||
if os.path.exists(model_path):
|
||||
os.remove(model_path)
|
||||
|
||||
def test_13c_scenery_generation_insufficient_references_checks(self):
|
||||
import edit_api
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
# Write dummy model image on disk
|
||||
model_filename = "test_scenery_insufficient_model.png"
|
||||
model_path = os.path.join(self.output_dir, model_filename)
|
||||
Image.new("RGB", (10, 10), color="red").save(model_path)
|
||||
|
||||
try:
|
||||
# Test 1: both scene_image and scene_video are missing -> raise HTTPException 400
|
||||
req_no_bg = edit_api.SceneryRequest(
|
||||
model_filename=model_filename,
|
||||
prompt="some prompt"
|
||||
)
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
edit_api.generate_scenery(req_no_bg)
|
||||
self.assertEqual(ctx.exception.status_code, 400)
|
||||
self.assertIn("requires a background scene reference", ctx.exception.detail)
|
||||
|
||||
# Test 2: inside _scenery_worker, if len(refs) < 2, it raises ValueError and sets status to error
|
||||
edit_api.jobs["test_scenery_insufficient_job"] = {"status": "running"}
|
||||
dummy_scene = Image.new("RGB", (10, 10), color="blue")
|
||||
edit_api._scenery_worker(
|
||||
job_id="test_scenery_insufficient_job",
|
||||
model_filename=model_filename,
|
||||
scene_pil=dummy_scene,
|
||||
prompt="test scenery prompt",
|
||||
seed=42,
|
||||
extra_pils=[],
|
||||
scene_video=None, # explicitly None to trigger the < 2 refs check
|
||||
scene_image=None, # explicitly None to trigger the < 2 refs check
|
||||
extra_filename=None
|
||||
)
|
||||
job = edit_api.jobs["test_scenery_insufficient_job"]
|
||||
self.assertEqual(job["status"], "error")
|
||||
self.assertIn("expected at least 2 source references", job["error"])
|
||||
|
||||
finally:
|
||||
if os.path.exists(model_path):
|
||||
os.remove(model_path)
|
||||
|
||||
def test_14_static_serialization_of_deep_metadata(self):
|
||||
import edit_api
|
||||
import database
|
||||
|
||||
Reference in New Issue
Block a user