feat: web UI zone mode selector with preset dropdown
This commit is contained in:
1 parent
7c91081ea8
commit
f0ea95dd26
3 files changed
+174
No files matched your search
@@ -19,6 +19,7 @@ from pathlib import Path
|
||||
from src.job import JobQueue
|
||||
from src.model_registry import scan_models, scan_model_groups, ModelConfig
|
||||
from src.preview import extract_thumbnail, extract_sample_frames
|
||||
from src.zone_config import load_zone_config, list_zone_presets, build_static_roi, get_active_zone
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -36,6 +37,7 @@ os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
job_queue = JobQueue(output_dir=OUTPUT_DIR)
|
||||
|
||||
VIDEO_EXTENSIONS = ('.mp4', '.avi', '.mkv', '.mov', '.webm')
|
||||
ZONES_PATH = os.getenv("ZONES_PATH", "./zones.json")
|
||||
|
||||
|
||||
def _parse_model_configs(form):
|
||||
@@ -125,10 +127,28 @@ def upload():
|
||||
n += 1
|
||||
video.save(video_path)
|
||||
|
||||
zone_mode = request.form.get("zone_mode", "dynamic")
|
||||
fixed_zone = None
|
||||
zone_preset = ""
|
||||
if zone_mode == "fixed" and os.path.isfile(ZONES_PATH):
|
||||
preset_name = request.form.get("zone_preset", "")
|
||||
zone = get_active_zone(ZONES_PATH, preset_name or None)
|
||||
if zone:
|
||||
import cv2
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
cap.release()
|
||||
fixed_zone = build_static_roi(zone, vw, vh)
|
||||
zone_preset = preset_name or "default"
|
||||
|
||||
job = job_queue.add_job(
|
||||
video_path=video_path,
|
||||
model_configs=model_configs,
|
||||
class_filters=class_filters,
|
||||
zone_mode=zone_mode,
|
||||
fixed_zone=fixed_zone,
|
||||
zone_preset=zone_preset,
|
||||
)
|
||||
return redirect(url_for("status", job_id=job.job_id))
|
||||
|
||||
@@ -144,10 +164,28 @@ def upload_reuse():
|
||||
if not model_configs:
|
||||
return "No models selected", 400
|
||||
|
||||
zone_mode = request.form.get("zone_mode", "dynamic")
|
||||
fixed_zone = None
|
||||
zone_preset = ""
|
||||
if zone_mode == "fixed" and os.path.isfile(ZONES_PATH):
|
||||
preset_name = request.form.get("zone_preset", "")
|
||||
zone = get_active_zone(ZONES_PATH, preset_name or None)
|
||||
if zone:
|
||||
import cv2
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
cap.release()
|
||||
fixed_zone = build_static_roi(zone, vw, vh)
|
||||
zone_preset = preset_name or "default"
|
||||
|
||||
job = job_queue.add_job(
|
||||
video_path=video_path,
|
||||
model_configs=model_configs,
|
||||
class_filters=class_filters,
|
||||
zone_mode=zone_mode,
|
||||
fixed_zone=fixed_zone,
|
||||
zone_preset=zone_preset,
|
||||
)
|
||||
return redirect(url_for("status", job_id=job.job_id))
|
||||
|
||||
@@ -224,6 +262,18 @@ def api_models():
|
||||
])
|
||||
|
||||
|
||||
@app.route("/api/zones")
|
||||
def api_zones():
|
||||
"""Return zone presets from zones.json."""
|
||||
if not os.path.isfile(ZONES_PATH):
|
||||
return jsonify([])
|
||||
try:
|
||||
presets = list_zone_presets(ZONES_PATH)
|
||||
return jsonify(presets)
|
||||
except Exception:
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@app.route("/api/jobs")
|
||||
def api_jobs():
|
||||
return jsonify([{
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
var existingSection = document.getElementById('existing-videos-section');
|
||||
var videoList = document.getElementById('video-list');
|
||||
var selectedExistingVideo = null;
|
||||
var zoneSection = document.getElementById('zone-section');
|
||||
var zoneFixedConfig = document.getElementById('zone-fixed-config');
|
||||
var zonePresetSelect = document.getElementById('zone-preset-select');
|
||||
var zoneRadios = document.querySelectorAll('input[name="zone_mode"]');
|
||||
|
||||
/* ------------------------------------------------
|
||||
Upload Zone — click, drag-drop, keyboard
|
||||
@@ -115,6 +119,7 @@
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
updateSubmitState();
|
||||
showZoneSection();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
@@ -229,6 +234,7 @@
|
||||
// Hide upload zone, clear file input
|
||||
fileInput.value = '';
|
||||
previewWrap.classList.remove('visible');
|
||||
showZoneSection();
|
||||
} else {
|
||||
selectedExistingVideo = null;
|
||||
videoPathInput.value = '';
|
||||
@@ -266,6 +272,47 @@
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------
|
||||
Zone Mode — show/hide, fetch presets
|
||||
------------------------------------------------ */
|
||||
function showZoneSection() {
|
||||
if (zoneSection) zoneSection.style.display = '';
|
||||
}
|
||||
|
||||
function initZoneMode() {
|
||||
if (!zoneSection) return;
|
||||
|
||||
// Fetch presets from API
|
||||
fetch('/api/zones')
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (presets) {
|
||||
if (!presets || presets.length === 0) {
|
||||
var fixedRadio = zoneSection.querySelector('input[value="fixed"]');
|
||||
if (fixedRadio) fixedRadio.closest('label').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
zonePresetSelect.innerHTML = '';
|
||||
presets.forEach(function (p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.name;
|
||||
opt.textContent = p.name + (p.description ? ' — ' + p.description : '');
|
||||
zonePresetSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
zoneRadios.forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
var isFixed = zoneSection.querySelector('input[value="fixed"]').checked;
|
||||
if (isFixed) {
|
||||
zoneFixedConfig.classList.add('visible');
|
||||
} else {
|
||||
zoneFixedConfig.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------
|
||||
Submit — enable/disable, loading state
|
||||
------------------------------------------------ */
|
||||
@@ -310,6 +357,7 @@
|
||||
initUploadZone();
|
||||
initModelCards();
|
||||
initFormSubmit();
|
||||
initZoneMode();
|
||||
loadExistingVideos();
|
||||
|
||||
// Poll active jobs
|
||||
|
||||
@@ -270,6 +270,59 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Zone mode section */
|
||||
.zone-section { margin-top: var(--space-8); }
|
||||
.zone-section h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.zone-radio-group {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.zone-radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-neutral-700);
|
||||
cursor: pointer;
|
||||
}
|
||||
.zone-radio-group input[type="radio"] {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
.zone-fixed-config {
|
||||
display: none;
|
||||
padding: var(--space-4);
|
||||
background: var(--color-neutral-50);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.zone-fixed-config.visible { display: block; }
|
||||
.zone-preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.zone-preset-row label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-neutral-600);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.zone-preset-row select {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
border-radius: var(--radius-sm);
|
||||
background: white;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Video delete button */
|
||||
.video-delete-btn {
|
||||
flex-shrink: 0;
|
||||
@@ -341,6 +394,29 @@
|
||||
<div class="video-list" id="video-list"></div>
|
||||
</div>
|
||||
|
||||
{# --- Counting Zone --- #}
|
||||
<div class="zone-section" id="zone-section" style="display:none">
|
||||
<h3>Counting Zone</h3>
|
||||
<div class="zone-radio-group">
|
||||
<label>
|
||||
<input type="radio" name="zone_mode" value="dynamic" checked>
|
||||
Auto-detect Truck
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="zone_mode" value="fixed">
|
||||
Fixed Zone
|
||||
</label>
|
||||
</div>
|
||||
<div class="zone-fixed-config" id="zone-fixed-config">
|
||||
<div class="zone-preset-row">
|
||||
<label for="zone-preset-select">Preset:</label>
|
||||
<select name="zone_preset" id="zone-preset-select">
|
||||
<option value="">Loading presets...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- Model Selection --- #}
|
||||
<div class="model-section">
|
||||
<div class="model-section-header">
|
||||
|
||||
Reference in new issue
Block a user