GCC Code Coverage Report


Directory: ./
File: frontend/src/helpers/controller.cpp
Date: 2025-11-24 00:30:48
Exec Total Coverage
Lines: 0 505 0.0%
Functions: 0 43 0.0%
Branches: 0 686 0.0%

Line Branch Exec Source
1 #include "helpers/controller.hpp"
2 #include "geometry.hpp"
3 #include "helpers/str_label.hpp"
4 #include "stream.hpp"
5 #include "widgets/settings_panel.hpp"
6
7 #include <QCameraDevice>
8 #include <QColor>
9 #include <QDateTime>
10 #include <QDebug>
11 #include <QImage>
12 #include <QMediaDevices>
13 #include <QMetaObject>
14 #include <QThread>
15 #include <QtGlobal>
16 #include <chrono>
17
18 #include "event.hpp"
19 #include "frame.hpp"
20 #include "opencv_client.hpp"
21 #include "widgets/board.hpp"
22 #include "widgets/grid_view.hpp"
23 #include "widgets/stream_cell.hpp"
24
25 controller::controller(
26 yodau::backend::stream_manager* mgr, settings_panel* panel, board* zone,
27 QObject* parent
28 )
29 : QObject(parent)
30 , stream_mgr(mgr)
31 , settings(panel)
32 , main_zone(zone)
33 , grid(zone ? zone->grid_mode() : nullptr) {
34
35 init_from_backend();
36
37 if (stream_mgr) {
38 stream_mgr->set_analysis_interval_ms(66);
39 #ifdef YODAU_OPENCV
40 stream_mgr->set_frame_processor(
41 yodau::backend::opencv_motion_processor
42 );
43 #else
44 stream_mgr->set_frame_processor([](const yodau::backend::stream& s,
45 const yodau::backend::frame& f) {
46 Q_UNUSED(s);
47 Q_UNUSED(f);
48 return std::vector<yodau::backend::event> {};
49 });
50 #endif
51 stream_mgr->set_event_batch_sink(
52 [this](const std::vector<yodau::backend::event>& evs) {
53 on_backend_events(evs);
54 }
55 );
56 }
57
58 if (settings && grid) {
59 settings->set_active_candidates(grid->stream_names());
60 settings->set_active_current(QString());
61 }
62
63 setup_settings_connections();
64 setup_grid_connections();
65 }
66
67 void controller::update_analysis_caps() {
68 if (!stream_mgr || !grid) {
69 return;
70 }
71
72 const int n = static_cast<int>(grid->stream_names().size());
73 const int interval = repaint_interval_for_count(n);
74 stream_mgr->set_analysis_interval_ms(interval);
75 }
76
77 void controller::init_from_backend() {
78 if (!stream_mgr || !settings) {
79 return;
80 }
81
82 QSet<QString> names;
83
84 const auto backend_names = stream_mgr->stream_names();
85 for (auto& n : backend_names) {
86 const auto qname = QString::fromStdString(n);
87 names.insert(qname);
88
89 QString desc = str_label("<unknown>");
90 const auto s = stream_mgr->find_stream(n);
91 if (s) {
92 // const auto t = s->get_type();
93 const auto path = QString::fromStdString(s->get_path());
94 const auto type = QString::fromStdString(
95 yodau::backend::stream::type_name(s->get_type())
96 );
97 desc = QString("%1:%2").arg(type, path);
98 }
99
100 settings->add_stream_entry(qname, desc);
101 }
102
103 settings->set_existing_names(names);
104 }
105
106 void controller::handle_add_file(
107 const QString& path, const QString& name, const bool loop
108 ) {
109 handle_add_stream_common(path, name, "file", loop);
110 }
111
112 void controller::handle_add_local(const QString& source, const QString& name) {
113 handle_add_stream_common(source, name, "local", true);
114 }
115
116 void controller::handle_add_url(const QString& url, const QString& name) {
117 handle_add_stream_common(url, name, "url", true);
118 }
119
120 void controller::handle_detect_local_sources() {
121 if (!stream_mgr || !settings) {
122 return;
123 }
124
125 const auto ts = now_ts();
126 stream_mgr->refresh_local_streams();
127
128 QStringList locals;
129 const auto backend_names = stream_mgr->stream_names();
130 for (const auto& n : backend_names) {
131 const auto qn = QString::fromStdString(n);
132 if (qn.startsWith("video")) {
133 locals << qn;
134 }
135 }
136
137 settings->set_local_sources(locals);
138 settings->append_add_log(
139 QString("[%1] ok: detected %2 local sources").arg(ts).arg(locals.size())
140 );
141
142 const auto cams = QMediaDevices::videoInputs();
143 // for (int i = 0; i < cams.size(); ++i) {
144 // const auto& c = cams[i];
145 // }
146 }
147
148 void controller::handle_show_stream_changed(
149 const QString& name, const bool show
150 ) {
151 if (!grid) {
152 return;
153 }
154
155 if (show) {
156 grid->add_stream(name);
157 if (auto* tile = grid->peek_stream_cell(name)) {
158 connect(
159 tile, &stream_cell::frame_ready, this,
160 &controller::on_gui_frame, Qt::UniqueConnection
161 );
162 tile->set_persistent_lines(per_stream_lines.value(name));
163
164 const auto s = stream_mgr->find_stream(name.toStdString());
165 if (s) {
166 tile->set_loop(s->is_looping());
167 const auto path = QString::fromStdString(s->get_path());
168 const auto type = s->get_type();
169
170 if (type == yodau::backend::stream_type::local) {
171 tile->set_camera_id(path.toUtf8());
172 } else if (type == yodau::backend::stream_type::file) {
173 tile->set_source(QUrl::fromLocalFile(path));
174 } else {
175 tile->set_source(QUrl(path));
176 }
177 }
178 }
179 } else {
180 grid->remove_stream(name);
181 if (!active_name.isEmpty() && active_name == name && main_zone) {
182 if (auto* cell = main_zone->take_active_cell()) {
183 cell->deleteLater();
184 }
185 active_name.clear();
186 if (settings) {
187 settings->set_active_current(QString());
188 }
189 }
190 }
191
192 if (settings) {
193 settings->set_active_candidates(grid->stream_names());
194 }
195
196 update_repaint_caps();
197 update_analysis_caps();
198 }
199
200 void controller::handle_backend_event(const QString& text) {
201 if (settings) {
202 settings->append_active_log(text);
203 }
204 }
205
206 void controller::on_active_stream_selected(const QString& name) {
207 if (!main_zone) {
208 return;
209 }
210
211 active_name = name;
212
213 if (name.isEmpty()) {
214 main_zone->clear_active();
215 } else {
216 main_zone->set_active_stream(name);
217 }
218
219 if (auto* cell = main_zone->active_cell()) {
220 cell->set_labels_enabled(active_labels_enabled);
221
222 cell->clear_draft();
223 cell->set_drawing_enabled(drawing_new_mode);
224
225 if (drawing_new_mode) {
226 cell->set_draft_params(
227 draft_line_name, draft_line_color, draft_line_closed
228 );
229 } else if (settings) {
230 apply_template_preview(settings->active_template_current());
231 }
232 }
233
234 sync_active_persistent();
235 update_repaint_caps();
236 update_analysis_caps();
237 }
238
239 void controller::on_active_edit_mode_changed(bool drawing_new) {
240 drawing_new_mode = drawing_new;
241
242 if (!main_zone) {
243 return;
244 }
245
246 if (auto* cell = main_zone->active_cell()) {
247 cell->clear_draft();
248 cell->set_drawing_enabled(drawing_new);
249
250 if (drawing_new) {
251 cell->set_draft_params(
252 draft_line_name, draft_line_color, draft_line_closed
253 );
254 } else if (settings) {
255 apply_template_preview(settings->active_template_current());
256 }
257 }
258
259 if (settings) {
260 settings->append_active_log(
261 QString("edit mode: %1")
262 .arg(drawing_new ? "draw new" : "use template")
263 );
264 }
265 }
266
267 void controller::on_active_line_params_changed(
268 const QString& name, const QColor& color, bool closed
269 ) {
270 draft_line_name = name;
271 draft_line_color = color;
272 draft_line_closed = closed;
273
274 if (main_zone) {
275 if (auto* cell = main_zone->active_cell()) {
276 cell->set_draft_params(
277 draft_line_name, draft_line_color, draft_line_closed
278 );
279 }
280 }
281
282 if (settings) {
283 settings->append_active_log(
284 QString("active line params: name='%1' color=%2 closed=%3")
285 .arg(draft_line_name)
286 .arg(draft_line_color.name())
287 .arg(draft_line_closed ? "true" : "false")
288 );
289 }
290 }
291
292 void controller::on_active_line_save_requested(
293 const QString& name, const bool closed
294 ) {
295 log_active(QString("save click: name='%1' closed=%2 active='%3'")
296 .arg(name)
297 .arg(closed ? "true" : "false")
298 .arg(active_name));
299
300 auto* cell = active_cell_checked("add line");
301 if (!cell) {
302 return;
303 }
304
305 const auto pts = cell->draft_points_pct();
306 if (pts.size() < 2) {
307 log_active("add line failed: need at least 2 points");
308 return;
309 }
310
311 const auto points_str = points_str_from_pct(pts);
312 log_active(QString("points_str = %1").arg(points_str));
313
314 try {
315 const auto lp = stream_mgr->add_line(
316 points_str.toStdString(), closed, name.toStdString()
317 );
318
319 const auto final_name = QString::fromStdString(lp->name);
320 apply_added_line(cell, final_name, pts, closed);
321 } catch (const std::exception& e) {
322 log_active(QString("add line failed: %1").arg(e.what()));
323 }
324 }
325
326 void controller::on_active_template_selected(const QString& template_name) {
327 if (drawing_new_mode) {
328 return;
329 }
330 apply_template_preview(template_name);
331 }
332
333 void controller::on_active_template_color_changed(const QColor& color) {
334 Q_UNUSED(color);
335
336 if (drawing_new_mode) {
337 return;
338 }
339 if (!settings) {
340 return;
341 }
342
343 const auto t = settings->active_template_current();
344 if (t.isEmpty()) {
345 return;
346 }
347
348 apply_template_preview(t);
349 }
350
351 void controller::on_active_template_add_requested(
352 const QString& template_name, const QColor& color
353 ) {
354 auto* cell = active_cell_checked("add template");
355 if (!cell) {
356 return;
357 }
358
359 if (!templates.contains(template_name)) {
360 if (settings) {
361 settings->append_active_log(
362 QString("add template failed: unknown template '%1'")
363 .arg(template_name)
364 );
365 }
366 return;
367 }
368
369 const auto tpl = templates.value(template_name);
370
371 try {
372 stream_mgr->set_line(
373 active_name.toStdString(), template_name.toStdString()
374 );
375 } catch (const std::exception& e) {
376 if (settings) {
377 settings->append_active_log(
378 QString("add template failed: %1").arg(e.what())
379 );
380 }
381 return;
382 }
383
384 stream_cell::line_instance inst;
385 inst.template_name = template_name;
386 inst.color = color;
387 inst.closed = tpl.closed;
388 inst.pts_pct = tpl.pts_pct;
389
390 per_stream_lines[active_name].push_back(inst);
391 cell->add_persistent_line(inst);
392
393 if (settings) {
394 settings->append_active_log(
395 QString("template added to active: %1").arg(template_name)
396 );
397 }
398
399 cell->clear_draft();
400
401 if (settings) {
402 settings->reset_active_template_form();
403 }
404
405 sync_active_persistent();
406 }
407
408 void controller::on_active_line_undo_requested() {
409 if (!main_zone) {
410 return;
411 }
412
413 auto* cell = main_zone->active_cell();
414 if (!cell) {
415 return;
416 }
417
418 auto pts = cell->draft_points_pct();
419 if (pts.empty()) {
420 return;
421 }
422
423 pts.pop_back();
424 cell->set_draft_points_pct(pts);
425 }
426
427 void controller::on_active_labels_enabled_changed(bool on) {
428 active_labels_enabled = on;
429
430 if (!main_zone) {
431 return;
432 }
433
434 if (auto* cell = main_zone->active_cell()) {
435 cell->set_labels_enabled(active_labels_enabled);
436 }
437 }
438
439 void controller::setup_settings_connections() {
440 if (!settings || !main_zone) {
441 return;
442 }
443
444 connect(
445 settings, &settings_panel::active_stream_selected, this,
446 &controller::on_active_stream_selected
447 );
448
449 connect(
450 settings, &settings_panel::active_edit_mode_changed, this,
451 &controller::on_active_edit_mode_changed
452 );
453
454 connect(
455 settings, &settings_panel::active_line_params_changed, this,
456 &controller::on_active_line_params_changed
457 );
458
459 connect(
460 settings, &settings_panel::active_line_save_requested, this,
461 &controller::on_active_line_save_requested
462 );
463
464 connect(
465 settings, &settings_panel::active_template_add_requested, this,
466 &controller::on_active_template_add_requested
467 );
468
469 connect(
470 settings, &settings_panel::active_template_selected, this,
471 &controller::on_active_template_selected
472 );
473
474 connect(
475 settings, &settings_panel::active_template_color_changed, this,
476 &controller::on_active_template_color_changed
477 );
478
479 connect(
480 settings, &settings_panel::active_line_undo_requested, this,
481 &controller::on_active_line_undo_requested
482 );
483
484 connect(
485 settings, &settings_panel::active_labels_enabled_changed, this,
486 &controller::on_active_labels_enabled_changed
487 );
488 }
489
490 void controller::setup_grid_connections() {
491 if (!grid) {
492 return;
493 }
494
495 connect(grid, &grid_view::stream_closed, this, [this](const QString& name) {
496 if (settings) {
497 settings->set_stream_checked(name, false);
498 }
499 });
500
501 connect(
502 grid, &grid_view::stream_enlarge, this,
503 &controller::handle_enlarge_requested
504 );
505 }
506
507 void controller::handle_add_stream_common(
508 const QString& source, const QString& name, const QString& type, bool loop
509 ) {
510 if (!stream_mgr || !settings) {
511 return;
512 }
513
514 const auto ts = now_ts();
515
516 if (type == "url") {
517 const QUrl url(source);
518 const auto scheme = url.scheme().toLower();
519
520 if (!url.isValid() || scheme.isEmpty()) {
521 settings->append_add_log(
522 QString("[%1] error: invalid url '%2'").arg(ts, source)
523 );
524 return;
525 }
526
527 if (scheme != "rtsp" && scheme != "http" && scheme != "https") {
528 settings->append_add_log(
529 QString("[%1] error: unsupported url scheme '%2'")
530 .arg(ts, scheme)
531 );
532 return;
533 }
534 }
535
536 try {
537 const auto& s = stream_mgr->add_stream(
538 source.toStdString(), name.toStdString(), type.toStdString(), loop
539 );
540
541 const auto final_name = QString::fromStdString(s.get_name());
542 const auto source_desc = QString("%1:%2").arg(type, source);
543
544 QUrl url;
545 if (type == "file" || type == "local") {
546 url = QUrl::fromLocalFile(source);
547 } else {
548 url = QUrl(source);
549 }
550 stream_sources[final_name] = url;
551 stream_loops[final_name] = loop;
552
553 settings->append_add_log(
554 QString("[%1] ok: added %2 as %3").arg(ts, source_desc, final_name)
555 );
556
557 register_stream_in_ui(final_name, source_desc);
558 } catch (const std::exception& e) {
559 settings->append_add_log(
560 QString("[%1] error: add %2 failed: %3").arg(ts, type, e.what())
561 );
562 }
563 }
564
565 void controller::register_stream_in_ui(
566 const QString& final_name, const QString& source_desc
567 ) {
568 if (!settings) {
569 return;
570 }
571
572 settings->add_existing_name(final_name);
573 settings->add_stream_entry(final_name, source_desc);
574 settings->clear_add_inputs();
575
576 update_repaint_caps();
577 update_analysis_caps();
578 }
579
580 QString controller::now_ts() {
581 return QDateTime::currentDateTime().toString("HH:mm:ss");
582 }
583
584 void controller::handle_enlarge_requested(const QString& name) {
585 if (name.isEmpty()) {
586 return;
587 }
588
589 if (!active_name.isEmpty() && active_name == name) {
590 handle_back_to_grid();
591 return;
592 }
593
594 on_active_stream_selected(name);
595
596 if (settings) {
597 settings->set_active_current(name);
598 }
599 }
600
601 void controller::handle_back_to_grid() {
602 on_active_stream_selected(QString());
603
604 if (settings) {
605 settings->set_active_current(QString());
606 }
607 }
608
609 void controller::handle_thumb_activate(const QString& name) {
610 handle_enlarge_requested(name);
611 }
612
613 stream_cell* controller::active_cell_checked(const QString& fail_prefix) {
614 if (!stream_mgr || !main_zone || active_name.isEmpty()) {
615 if (settings) {
616 settings->append_active_log(
617 QString("%1 failed: no active stream").arg(fail_prefix)
618 );
619 }
620 return nullptr;
621 }
622
623 auto* cell = main_zone->active_cell();
624 if (!cell) {
625 if (settings) {
626 settings->append_active_log(
627 QString("%1 failed: active cell not found").arg(fail_prefix)
628 );
629 }
630 return nullptr;
631 }
632
633 return cell;
634 }
635
636 void controller::sync_active_persistent() {
637 if (!main_zone || active_name.isEmpty()) {
638 if (settings) {
639 settings->set_template_candidates({});
640 }
641 return;
642 }
643
644 sync_active_cell_lines();
645
646 if (!settings) {
647 return;
648 }
649
650 const auto used = used_template_names_for_stream(active_name);
651 settings->set_template_candidates(template_candidates_excluding(used));
652 }
653
654 void controller::apply_template_preview(const QString& template_name) {
655 if (!main_zone) {
656 return;
657 }
658 auto* cell = main_zone->active_cell();
659 if (!cell) {
660 return;
661 }
662
663 cell->clear_draft();
664
665 if (template_name.isEmpty() || !templates.contains(template_name)) {
666 return;
667 }
668
669 const auto tpl = templates.value(template_name);
670
671 QColor c = Qt::red;
672 if (settings) {
673 c = settings->active_template_preview_color();
674 }
675
676 cell->set_draft_params(template_name, c, tpl.closed);
677 cell->set_draft_points_pct(tpl.pts_pct);
678 }
679
680 void controller::log_active(const QString& msg) const {
681 if (settings) {
682 settings->append_active_log(msg);
683 }
684 }
685
686 QString controller::points_str_from_pct(const std::vector<QPointF>& pts) {
687 QStringList parts;
688 parts.reserve(static_cast<int>(pts.size()));
689 for (const auto& p : pts) {
690 parts << QString("(%1,%2)").arg(p.x(), 0, 'f', 3).arg(p.y(), 0, 'f', 3);
691 }
692 return parts.join("; ");
693 }
694
695 void controller::apply_added_line(
696 stream_cell* cell, const QString& final_name,
697 const std::vector<QPointF>& pts, const bool closed
698 ) {
699 stream_cell::line_instance inst;
700 inst.template_name = final_name;
701 inst.color = draft_line_color;
702 inst.closed = closed;
703 inst.pts_pct = pts;
704
705 per_stream_lines[active_name].push_back(inst);
706 cell->add_persistent_line(inst);
707
708 templates[final_name] = tpl_line { pts, closed };
709
710 stream_mgr->set_line(active_name.toStdString(), final_name.toStdString());
711
712 cell->clear_draft();
713 cell->set_draft_params(QString(), QColor(Qt::red), false);
714
715 draft_line_name.clear();
716 draft_line_color = Qt::red;
717 draft_line_closed = false;
718
719 if (settings) {
720 settings->reset_active_line_form();
721 settings->add_template_candidate(final_name);
722 settings->reset_active_template_form();
723 }
724
725 log_active(
726 QString("line added: %1 (%2 points)").arg(final_name).arg(pts.size())
727 );
728
729 sync_active_persistent();
730 }
731
732 void controller::sync_active_cell_lines() const {
733 if (!main_zone) {
734 return;
735 }
736
737 if (auto* cell = main_zone->active_cell()) {
738 cell->set_persistent_lines(per_stream_lines.value(active_name));
739 }
740 }
741
742 QSet<QString>
743 controller::used_template_names_for_stream(const QString& stream) const {
744 QSet<QString> used;
745
746 const auto current_lines = per_stream_lines.value(stream);
747 for (const auto& inst : current_lines) {
748 const auto tn = inst.template_name.trimmed();
749 if (!tn.isEmpty()) {
750 used.insert(tn);
751 }
752 }
753
754 return used;
755 }
756
757 QStringList
758 controller::template_candidates_excluding(const QSet<QString>& used) const {
759 QStringList candidates;
760 candidates.reserve(templates.size());
761
762 for (auto it = templates.begin(); it != templates.end(); ++it) {
763 const QString name = it.key();
764 if (!used.contains(name)) {
765 candidates << name;
766 }
767 }
768
769 return candidates;
770 }
771
772 void controller::update_repaint_caps() {
773 if (!grid) {
774 return;
775 }
776
777 const auto names = grid->stream_names();
778 const int n = static_cast<int>(names.size());
779 const int interval = repaint_interval_for_count(n);
780
781 for (const auto& name : names) {
782 if (auto* tile = grid->peek_stream_cell(name)) {
783 tile->set_repaint_interval_ms(interval);
784 }
785 }
786
787 if (!active_name.isEmpty()) {
788 if (auto* cell = grid->peek_stream_cell(active_name)) {
789 cell->set_repaint_interval_ms(active_interval_ms);
790 }
791 }
792 }
793
794 void controller::on_backend_events(
795 const std::vector<yodau::backend::event>& evs
796 ) {
797 for (const auto& e : evs) {
798 on_backend_event(e);
799 }
800 }
801
802 int controller::repaint_interval_for_count(const int n) {
803 if (n <= 2) {
804 return 33;
805 }
806
807 if (n <= 4) {
808 return 66;
809 }
810
811 if (n <= 9) {
812 return 100;
813 }
814
815 return 166;
816 }
817
818 stream_cell* controller::tile_for_stream_name(const QString& name) const {
819 if (main_zone && !active_name.isEmpty() && active_name == name) {
820 if (auto* cell = main_zone->active_cell()) {
821 return cell;
822 }
823 }
824
825 if (grid) {
826 if (auto* tile = grid->peek_stream_cell(name)) {
827 return tile;
828 }
829 }
830
831 return nullptr;
832 }
833
834 yodau::backend::frame controller::frame_from_image(const QImage& image) const {
835 QImage img = image;
836
837 if (img.format() != QImage::Format_RGB888) {
838 img = img.convertToFormat(QImage::Format_RGB888);
839 }
840
841 yodau::backend::frame f;
842 f.width = img.width();
843 f.height = img.height();
844 f.stride = static_cast<int>(img.bytesPerLine());
845 f.format = yodau::backend::pixel_format::rgb24;
846 f.ts = std::chrono::steady_clock::now();
847
848 const auto* ptr = img.constBits();
849 const int bytes = static_cast<int>(img.sizeInBytes());
850 if (ptr && bytes > 0) {
851 f.data.assign(ptr, ptr + bytes);
852 }
853
854 return f;
855 }
856
857 void controller::on_gui_frame(const QString& stream_name, const QImage& image) {
858 if (!stream_mgr) {
859 return;
860 }
861
862 auto f = frame_from_image(image);
863 stream_mgr->push_frame(stream_name.toStdString(), std::move(f));
864 }
865
866 void controller::on_backend_event(const yodau::backend::event& e) {
867 if (QThread::currentThread() != thread()) {
868 const auto copy = e;
869 QMetaObject::invokeMethod(
870 this, [this, copy]() { on_backend_event(copy); },
871 Qt::QueuedConnection
872 );
873 return;
874 }
875
876 const auto name = QString::fromStdString(e.stream_name);
877 auto* tile = tile_for_stream_name(name);
878 if (!tile) {
879 return;
880 }
881
882 if (!e.pos_pct.has_value()) {
883 return;
884 }
885
886 if (e.kind == yodau::backend::event_kind::tripwire) {
887 if (!e.line_name.empty()) {
888 const auto ln = QString::fromStdString(e.line_name);
889 const auto& p = *e.pos_pct;
890 tile->highlight_line_at(ln, QPointF(p.x, p.y));
891 }
892 }
893
894 const auto& p = *e.pos_pct;
895 tile->add_event(QPointF(p.x, p.y), Qt::gray);
896 }
897