Add buffered crop UI and API controls

This commit is contained in:
2026-04-11 16:33:45 +02:00
parent 660ce4e7cc
commit 3b5a9e8635
2 changed files with 28 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import HTMLResponse, StreamingResponse
from app.config import settings from app.config import settings
@@ -33,30 +33,45 @@ def index():
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4"> <section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<input id="file" type="file" accept="image/*" class="block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" /> <input id="file" type="file" accept="image/*" class="block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
<label class="mt-4 block text-sm text-slate-400">Buffer ratio</label>
<input id="buffer_ratio" type="number" step="0.05" min="0" max="0.5" value="0.15" class="block w-full rounded-lg border border-slate-700 bg-slate-950 p-3" />
<button id="go" class="mt-4 rounded-lg bg-cyan-500 px-4 py-2 font-semibold text-slate-950">Process</button> <button id="go" class="mt-4 rounded-lg bg-cyan-500 px-4 py-2 font-semibold text-slate-950">Process</button>
<pre id="meta" class="mt-4 whitespace-pre-wrap rounded-lg bg-slate-950 p-3 text-xs text-slate-300"></pre> <pre id="meta" class="mt-4 whitespace-pre-wrap rounded-lg bg-slate-950 p-3 text-xs text-slate-300"></pre>
</section> </section>
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-4"> <section class="rounded-2xl border border-slate-800 bg-slate-900 p-4">
<div class="mb-3 text-sm font-semibold text-slate-400">Result</div> <div class="mb-3 text-sm font-semibold text-slate-400">Result</div>
<img id="result" class="hidden w-full rounded-xl border border-slate-800" /> <div class="grid gap-4">
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Crop</div>
<img id="crop" class="hidden w-full rounded-xl border border-slate-800" />
</div>
<div>
<div class="mb-2 text-xs uppercase tracking-wide text-slate-500">Annotated source</div>
<img id="annotated" class="hidden w-full rounded-xl border border-slate-800" />
</div>
</div>
</section> </section>
</div> </div>
</main> </main>
<script> <script>
const file = document.getElementById('file'); const file = document.getElementById('file');
const go = document.getElementById('go'); const go = document.getElementById('go');
const result = document.getElementById('result'); const crop = document.getElementById('crop');
const annotated = document.getElementById('annotated');
const meta = document.getElementById('meta'); const meta = document.getElementById('meta');
go.onclick = async () => { go.onclick = async () => {
if (!file.files.length) return; if (!file.files.length) return;
const form = new FormData(); const form = new FormData();
form.append('file', file.files[0]); form.append('file', file.files[0]);
form.append('buffer_ratio', document.getElementById('buffer_ratio').value);
meta.textContent = 'Working...'; meta.textContent = 'Working...';
const resp = await fetch('/api/focus', { method: 'POST', body: form }); const resp = await fetch('/api/focus', { method: 'POST', body: form });
const data = await resp.json(); const data = await resp.json();
meta.textContent = JSON.stringify(data, null, 2); meta.textContent = JSON.stringify(data, null, 2);
result.src = data.crop_data_url; crop.src = data.crop_data_url;
result.classList.remove('hidden'); annotated.src = data.annotated_data_url;
crop.classList.remove('hidden');
annotated.classList.remove('hidden');
}; };
</script> </script>
</body> </body>
@@ -66,23 +81,23 @@ def index():
@app.post("/api/focus") @app.post("/api/focus")
async def focus(file: UploadFile = File(...)): async def focus(file: UploadFile = File(...), buffer_ratio: float = Form(0.15)):
from app.vision import process_image from app.vision import process_image
try: try:
payload = await file.read() payload = await file.read()
return process_image(payload, file.filename or "upload") return process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/focus/image") @app.post("/api/focus/image")
async def focus_image(file: UploadFile = File(...)): async def focus_image(file: UploadFile = File(...), buffer_ratio: float = Form(0.15)):
from app.vision import process_image from app.vision import process_image
try: try:
payload = await file.read() payload = await file.read()
result = process_image(payload, file.filename or "upload") result = process_image(payload, file.filename or "upload", buffer_ratio=buffer_ratio)
return StreamingResponse(result["crop_bytes_io"], media_type=result["mime_type"]) return StreamingResponse(result["crop_bytes_io"], media_type=result["mime_type"])
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@@ -89,6 +89,7 @@ def detect_salient_object(image: np.ndarray) -> BBox | None:
def square_bbox(bbox: BBox, image_shape: tuple[int, int, int], buffer_ratio: float = 0.15) -> BBox: def square_bbox(bbox: BBox, image_shape: tuple[int, int, int], buffer_ratio: float = 0.15) -> BBox:
image_h, image_w = image_shape[:2] image_h, image_w = image_shape[:2]
buffer_ratio = max(0.0, min(buffer_ratio, 0.5))
side = int(round(max(bbox.w, bbox.h) * (1 + buffer_ratio * 2))) side = int(round(max(bbox.w, bbox.h) * (1 + buffer_ratio * 2)))
side = max(1, min(side, image_w, image_h)) side = max(1, min(side, image_w, image_h))
@@ -126,10 +127,10 @@ def _data_url(image_bytes: bytes, mime_type: str) -> str:
return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('ascii')}" return f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('ascii')}"
def process_image(image_bytes: bytes, filename: str) -> dict[str, Any]: def process_image(image_bytes: bytes, filename: str, buffer_ratio: float = 0.15) -> dict[str, Any]:
image = decode_image(image_bytes) image = decode_image(image_bytes)
bbox, method = select_primary_bbox(image) bbox, method = select_primary_bbox(image)
square = square_bbox(bbox, image.shape) square = square_bbox(bbox, image.shape, buffer_ratio=buffer_ratio)
crop = crop_image(image, square) crop = crop_image(image, square)
annotated = draw_square(image, square) annotated = draw_square(image, square)
@@ -139,6 +140,7 @@ def process_image(image_bytes: bytes, filename: str) -> dict[str, Any]:
return { return {
"filename": filename, "filename": filename,
"method": method, "method": method,
"buffer_ratio": buffer_ratio,
"detected_bbox": bbox.__dict__, "detected_bbox": bbox.__dict__,
"square_bbox": square.__dict__, "square_bbox": square.__dict__,
"source_size": {"width": int(image.shape[1]), "height": int(image.shape[0])}, "source_size": {"width": int(image.shape[1]), "height": int(image.shape[0])},