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.job import JobQueue
|
||||||
from src.model_registry import scan_models, scan_model_groups, ModelConfig
|
from src.model_registry import scan_models, scan_model_groups, ModelConfig
|
||||||
from src.preview import extract_thumbnail, extract_sample_frames
|
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()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|||||||
job_queue = JobQueue(output_dir=OUTPUT_DIR)
|
job_queue = JobQueue(output_dir=OUTPUT_DIR)
|
||||||
|
|
||||||
VIDEO_EXTENSIONS = ('.mp4', '.avi', '.mkv', '.mov', '.webm')
|
VIDEO_EXTENSIONS = ('.mp4', '.avi', '.mkv', '.mov', '.webm')
|
||||||
|
ZONES_PATH = os.getenv("ZONES_PATH", "./zones.json")
|
||||||
|
|
||||||
|
|
||||||
def _parse_model_configs(form):
|
def _parse_model_configs(form):
|
||||||
@@ -125,10 +127,28 @@ def upload():
|
|||||||
n += 1
|
n += 1
|
||||||
video.save(video_path)
|
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(
|
job = job_queue.add_job(
|
||||||
video_path=video_path,
|
video_path=video_path,
|
||||||
model_configs=model_configs,
|
model_configs=model_configs,
|
||||||
class_filters=class_filters,
|
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))
|
return redirect(url_for("status", job_id=job.job_id))
|
||||||
|
|
||||||
@@ -144,10 +164,28 @@ def upload_reuse():
|
|||||||
if not model_configs:
|
if not model_configs:
|
||||||
return "No models selected", 400
|
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(
|
job = job_queue.add_job(
|
||||||
video_path=video_path,
|
video_path=video_path,
|
||||||
model_configs=model_configs,
|
model_configs=model_configs,
|
||||||
class_filters=class_filters,
|
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))
|
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")
|
@app.route("/api/jobs")
|
||||||
def api_jobs():
|
def api_jobs():
|
||||||
return jsonify([{
|
return jsonify([{
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
var existingSection = document.getElementById('existing-videos-section');
|
var existingSection = document.getElementById('existing-videos-section');
|
||||||
var videoList = document.getElementById('video-list');
|
var videoList = document.getElementById('video-list');
|
||||||
var selectedExistingVideo = null;
|
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
|
Upload Zone — click, drag-drop, keyboard
|
||||||
@@ -115,6 +119,7 @@
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
updateSubmitState();
|
updateSubmitState();
|
||||||
|
showZoneSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
@@ -229,6 +234,7 @@
|
|||||||
// Hide upload zone, clear file input
|
// Hide upload zone, clear file input
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
previewWrap.classList.remove('visible');
|
previewWrap.classList.remove('visible');
|
||||||
|
showZoneSection();
|
||||||
} else {
|
} else {
|
||||||
selectedExistingVideo = null;
|
selectedExistingVideo = null;
|
||||||
videoPathInput.value = '';
|
videoPathInput.value = '';
|
||||||
@@ -266,6 +272,47 @@
|
|||||||
.catch(function () {});
|
.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
|
Submit — enable/disable, loading state
|
||||||
------------------------------------------------ */
|
------------------------------------------------ */
|
||||||
@@ -310,6 +357,7 @@
|
|||||||
initUploadZone();
|
initUploadZone();
|
||||||
initModelCards();
|
initModelCards();
|
||||||
initFormSubmit();
|
initFormSubmit();
|
||||||
|
initZoneMode();
|
||||||
loadExistingVideos();
|
loadExistingVideos();
|
||||||
|
|
||||||
// Poll active jobs
|
// Poll active jobs
|
||||||
|
|||||||
@@ -270,6 +270,59 @@
|
|||||||
font-size: var(--text-sm);
|
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 button */
|
||||||
.video-delete-btn {
|
.video-delete-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -341,6 +394,29 @@
|
|||||||
<div class="video-list" id="video-list"></div>
|
<div class="video-list" id="video-list"></div>
|
||||||
</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 --- #}
|
{# --- Model Selection --- #}
|
||||||
<div class="model-section">
|
<div class="model-section">
|
||||||
<div class="model-section-header">
|
<div class="model-section-header">
|
||||||
|
|||||||
Reference in new issue
Block a user