diff --git a/app.py b/app.py index aa882ec..665b306 100644 --- a/app.py +++ b/app.py @@ -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([{ diff --git a/static/app.js b/static/app.js index 2b5a764..45f6406 100644 --- a/static/app.js +++ b/static/app.js @@ -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 diff --git a/templates/index.html b/templates/index.html index 9ef8411..e2f62fc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 @@
+ {# --- Counting Zone --- #} + + {# --- Model Selection --- #}