4 pointageWidget, a module for pymecavideo:
5 a program to track moving points
in a video frameset
7 Copyright (C) 2007 Jean-Baptiste Butet <ashashiwa
@gmail.com>
8 Copyright (C) 2023 Georges Khaznadar <georgesk
@debian.org>
10 This program
is free software: you can redistribute it
and/
or modify
11 it under the terms of the GNU General Public License
as published by
12 the Free Software Foundation, either version 3 of the License,
or
13 (at your option) any later version.
15 This program
is distributed
in the hope that it will be useful,
16 but WITHOUT ANY WARRANTY; without even the implied warranty of
17 MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the
18 GNU General Public License
for more details.
20 You should have received a copy of the GNU General Public License
21 along
with this program. If
not, see <http://www.gnu.org/licenses/>.
24from PyQt6.QtCore import QThread, pyqtSignal, QLocale, QTranslator, Qt, \
25 QSize, QTimer, QObject, QRect, QPoint, QPointF, QEvent
26from PyQt6.QtGui
import QKeySequence, QIcon, QPixmap, QImage, QPainter, \
27 QCursor, QPen, QColor, QFont, QResizeEvent, QShortcut
28from PyQt6.QtWidgets
import QApplication, QMainWindow, QWidget, QLayout, \
29 QFileDialog, QTableWidgetItem, QInputDialog, QLineEdit, QMessageBox, \
30 QTableWidgetSelectionRange
32import os, time, re, sys
35from version
import Version
36from vecteur
import vecteur
37from echelle
import Echelle_TraceWidget
38from image_widget
import ImageWidget
39from pointage
import Pointage
40from globdef
import cible_icon, DOCUMENT_PATH, inhibe, pattern_float
41from cadreur
import openCvReader
42from toQimage
import toQImage
43from suivi_auto
import SelRectWidget
44from detect
import filter_picture
46from suivi_auto
import SelRectWidget
47from choix_origine
import ChoixOrigineWidget
48from pointage
import Pointage
49from etatsPointage
import Etats
50from echelle
import EchelleWidget, echelle
51from detect
import filter_picture
53import interfaces.icon_rc
55from interfaces.Ui_pointage
import Ui_pointageWidget
59 Une classe qui affiche l'image d'une vidéo et qui gère le pointage
60 d
'objets mobiles dans cette vidéo. Elle gère les données de pointage
61 et celles liées aux échelles de temps et d'espace
63 paramètres du constructeur :
64 @param parent un QWidget parent
65 @param verbosite entre 1 et 3 pour les messages de débogage
68 def __init__(self, parent, verbosite = 0):
70 QWidget.__init__(self, parent)
71 Ui_pointageWidget.__init__(self)
72 Pointage.__init__(self)
102 cible_pix = QPixmap(cible_icon).scaledToHeight(32)
110 update_imgedit = pyqtSignal(int, int, int)
111 update_origine = pyqtSignal(float, float)
112 dimension_data = pyqtSignal(int)
113 stopCalculs = pyqtSignal()
114 label_zoom = pyqtSignal(str)
115 update_zoom = pyqtSignal(vecteur)
116 echelle_modif = pyqtSignal(str, str)
117 apres_echelle = pyqtSignal()
118 selection_motif_done = pyqtSignal()
119 fin_pointage = pyqtSignal()
120 fin_pointage_manuel = pyqtSignal(QEvent)
121 stop_n = pyqtSignal(str)
122 change_axe_origine = pyqtSignal()
123 sens_axes = pyqtSignal(int, int)
124 montre_etalon = pyqtSignal()
147 Connecte des signaux issus de l'UI
168 Crée une relation avec la fenêtre principale, son débogueur et
170 @param app le fenêtre principale (QMainWindoWidget)
174 self.prefs = app.prefs
175 self.video.setParent(self)
184 @param rx ratio horizontal
185 @param ry ratio vertical
192 Affiche la taille de l'image
193 @param w largeur de l
'image
194 @param h hauteur de l
'image
195 @param r rotation de l
'image
202 Ouvre le fichier de nom filename, enregistre les préférences de
204 @param filename nom du fichier
206 self.dbg.p(2, "rentre dans 'openTheFile'")
217 self.
app.change_etat.emit(
"debut")
218 self.
app.change_etat.emit(
"A")
222 self.tr(
"Erreur lors de la lecture du fichier"),
223 self.tr(
"Le fichier<b>{0}</b> ...\nn'est peut-être pas dans un format vidéo supporté.").format(
229 Récupère les préférences sauvegardées, et en applique les données
230 ici on s'occupe de ce qui se gère facilement au niveau du widget
232 @param rouvre est vrai quand on ouvre un fichier pymecavideo ;
233 il est faux par défaut
235 self.dbg.p(2, "rentre dans 'VideoWidget.apply_preferences'")
236 d = self.
app.prefs.config[
"DEFAULT"]
238 self.
video.rotation = d.getint(
"rotation")
253 self.
deltatT = d.getfloat(
"deltat")
255 self.
echelle_image.longueur_reelle_etalon = d.getfloat(
'etalon_m')
256 p1 = self.
app.prefs.config.getvecteur(
"DEFAULT",
"etalon_org")
257 p2 = self.
app.prefs.config.getvecteur(
"DEFAULT",
"etalon_ext")
270 Initialise le lecteur de flux vidéo pour OpenCV
271 et recode la vidéo si nécessaire.
273 self.dbg.p(2, "rentre dans 'init_cvReader', ouverture de %s" %
280 self.tr(
"Format vidéo non pris en charge"),
281 self.tr(
"Le format de cette vidéo n'est pas pris en charge par pymecavideo"))
287 initialise certaines variables lors le la mise en place d'une
290 self.dbg.p(2, "rentre dans 'init_image'")
304 extrait une image de la video à l'aide d'OpenCV ; met à jour
305 self.
pointageOK s
'il est licite de pointer dans l'état actuel;
306 met à jour le curseur à utiliser aussi
307 @param index le numéro de l
'image (commence à 1)
309 @return un boolen (ok), et l
'image au format d'openCV ; l
'image
312 self.dbg.p(2, "rentre dans 'extract_image' " +
'index : ' + str(index))
313 ok, image_opencv = self.
cvReader.getImage(index, self.
video.rotation)
315 self.
app.affiche_statut.emit(
316 self.tr(
"Pymecavideo n'arrive pas à lire l'image"))
331 self.
video.setCursor(Qt.CursorShape.ArrowCursor)
332 return ok, image_opencv
334 def calcul_deltaT(self, ips_from_line_edit=False, rouvre=False):
336 Détermination de l'intervalle de temps entre deux images.
339 @param ips_from_line_edit (faux par défaut) indique qu
'on lit
340 deltaT depuis un champ de saisie
341 @param rouvre (faux par défaut) indique qu
'on lit les données depuis
342 un fichier pymecavidéo
344 self.dbg.p(2, "rentre dans 'calcul_deltaT'")
348 self.
app.lineEdit_IPS.setText(str(IPS))
350 if not ips_from_line_edit:
362 redimensionne self.data (fonction de rappel connectée au signal
364 @param dim la nouvelle dimension des données
365 (en nombre d
'objets à suivre)
367 self.dbg.p(2, "rentre dans 'redimensionne_data'")
373 def verifie_IPS(self):
374 self.
dbg.p(2,
"rentre dans 'verifie_IPS'")
379 self.tr(
"Le nombre d'images par seconde doit être un entier"),
380 self.tr(
"merci de recommencer"))
392 demande l'échelle interactivement
394 self.dbg.p(2, "rentre dans 'demande_echelle'")
395 reponse, ok = QInputDialog.getText(
397 self.tr(
"Définir léchelle"),
398 self.tr(
"Quelle est la longueur en mètre de votre étalon sur l'image ?"),
399 text = f
"{self.echelle_image.longueur_reelle_etalon:.3f}")
400 reponse = reponse.replace(
",",
".")
401 ok = ok
and pattern_float.match(reponse)
and float(reponse) > 0
403 self.affiche_statut.emit(self.tr(
404 "Merci d'indiquer une échelle valable : {} ne peut pas être converti en nombre.").format(reponse))
407 reponse = float(reponse)
409 self.
app.change_etat.emit(
"C")
416 Fonction de rappel du bouton Bouton_lance_capture
418 Passe à l'état D ou AB, selon self.checkBox_auto
420 self.app.change_etat.emit(
426 Permet de déplacer l'origine du référentiel de la caméra
428 self.dbg.p(2, "rentre dans 'nouvelle_origine'")
429 nvl_origine = QMessageBox.information(
431 self.tr(
"NOUVELLE ORIGINE"),
432 self.tr(
"Choisissez, en cliquant sur la vidéo le point qui sera la nouvelle origine"))
436 def change_sens_X(self):
437 self.
dbg.p(2,
"rentre dans 'change_sens_X'")
445 def change_sens_Y(self):
446 self.
dbg.p(2,
"rentre dans 'change_sens_Y'")
454 def tourne_droite(self):
455 self.
dbg.p(2,
"rentre dans 'tourne_droite'")
458 def tourne_gauche(self):
459 self.
dbg.p(2,
"rentre dans 'tourne_droite'")
462 def tourne_image(self, sens):
463 self.
dbg.p(2,
"rentre dans 'tourne_image'")
466 elif sens ==
"gauche":
468 self.
video.rotation = (self.
video.rotation + increment) % 360
469 self.
dbg.p(2,
"Dans 'tourne_image' self rotation vaut" +
470 str(self.
video.rotation))
473 self.
dbg.p(3, f
"Dans 'tourne_image' avant de tourner, self.origine {self.origine}, largeur video {self.video.width()}, hauteur video {self.video.height()}")
474 self.redimensionneSignal.emit(
True)
479 À condition qu'on ait ouvert le fichier vidéo,
480 extrait l'image courante ou l'image spécifiée
481 par l
'index, et affiche cette image
482 @param index permet de modifier l
'image courante si c'est un entier
485 if index
is not None: self.
index = index
488 self.
dbg.p(2, f
"rentre dans 'affiche_image' self.index = {self.index} self.image_max = {self.image_max}")
491 self.
video.placeImage(
501 Replace l'origine au centre de l'image
508 Efface toutes les données de la capture en cours et prépare une nouvelle
509 session de capture. Retourne à l'état A
511 self.dbg.p(2, "rentre dans 'reinitialise_capture'")
518 "background-color:None;")
522 self.setCursor(Qt.CursorShape.ArrowCursor)
523 self.setEnabled(
True)
531 self.
app.change_etat.emit(
"A")
536 Il peut être nécessaire de remontrer l'image après un changement
537 de self.video.rotation
548 revient au point précédent
550 self.dbg.p(2, "rentre dans 'efface_point_precedent'")
551 if inhibe(
"defaire",100):
return
565 """rétablit le point suivant après un effacement
567 self.dbg.p(2, "rentre dans 'refait_point_suivant'")
568 if inhibe(
"refaire",100):
return
571 self.
app.recalculLesCoordonnees()
577 def enregistre_ui(self):
578 self.
dbg.p(2,
"rentre dans 'enregistre_ui'")
580 base_name = os.path.splitext(os.path.basename(self.
filename))[0]
581 defaultName = os.path.join(DOCUMENT_PATH, base_name+
'.mecavideo')
582 fichier = QFileDialog.getSaveFileName(
584 self.tr(
"Enregistrer le projet pymecavideo"),
586 self.tr(
"Projet pymecavideo (*.mecavideo)"))
589 QMessageBox.critical(
None, self.tr(
"Erreur lors de l'enregistrement"), self.tr(
"Il manque les données, ou l'échelle"))
594 Enregistre les données courantes dans un fichier,
595 à un format CSV, séparé par des tabulations
597 self.dbg.p(2, "rentre dans 'enregistre'")
599 if locale.getdefaultlocale()[0][0:2] ==
'fr':
607 with open(fichier,
'w')
as outfile:
608 message = self.tr(
"temps en seconde, positions en mètre")
609 outfile.write(self.entete_fichier(message))
611 sep =
"\t", unite =
"m",
614 ).replace(
".",sep_decimal)
615 outfile.write(donnees)
619 d = self.
app.prefs.defaults
620 d[
'version'] = f
"pymecavideo {Version}"
621 d[
'proximite'] = str(self.
app.radioButtonNearMouse.isChecked())
623 d[
'videodir'] = os.path.dirname(self.
filename)
624 d[
'niveaudbg'] = str(self.
dbg.verbosite)
627 d[
"taille_image"] = f
"({self.image_w},{self.image_h})"
628 d[
'rotation'] = str(self.
video.rotation)
629 d[
'origine'] = f
"({round(self.origine.x)}, {round(self.origine.y)})"
631 d[
'etalon_m'] = str(self.
echelle_image.longueur_reelle_etalon)
632 d[
'etalon_px'] = str(self.
echelle_image.longueur_pixel_etalon())
638 d[
'nb_obj'] = str(len(self.
suivis))
639 self.
app.prefs.save()
641 def stopComputing(self):
642 self.
dbg.p(2,
"rentre dans 'stopComputing'")
650 Met à jour le label au-dessus du zoom
651 @param label le nouveau label
658 Gère les deux widgets horizontalSlider et spinBox_image
659 @param state si state ==
True, les deux widgets sont activés
660 et leurs signaux valueChanged sont pris en compte ;
661 sinon ils sont désactivés ainsi que les signaux valueChanged
663 self.dbg.p(2, "rentre dans 'imgControlImage'")
684 affiche l'échelle courante pour les distances sur l'image
686 self.dbg.p(2, "rentre dans 'affiche_echelle'")
702 Contrôle la possibilité de défaire un clic
705 self.dbg.p(2, "rentre dans 'enableDefaire, %s'" % (str(value)))
707 self.
app.actionDefaire.setEnabled(value)
715 Contrôle la possibilité de refaire un clic
718 self.dbg.p(2, "rentre dans 'enableRefaire, %s'" % (value))
720 self.
app.actionRefaire.setEnabled(value)
723 def loupe(self, position):
725 Agrandit deux fois une partie de self.video.image et la met
726 dans la zone du zoom, puis met à jour les affichages de coordonnées ;
727 sauf dans l'état B (pointage auto)
728 @param position le centre de la zone à agrandir
732 xpx, ypx, xm, ym = self.
coords(position)
735 self.
editXm.setText(f
"{xm}")
736 self.
editYm.setText(f
"{ym}")
741 @param p un point, vecteur de coordonnées entières
742 @return les valeurs de x, y en px et puis en mètre (formatées :.2e)
750 return int(p.x), int(p.y), self.tr(
"indéf."), self.tr(
"indéf.")
751 return int(p.x), int(p.y), \
752 f
"{p.x/self.echelle_image.pxParM():.2e}", \
753 f
"{p.y/self.echelle_image.pxParM():.2e}"
757 Signale fortement qu'il est possible de refaire l'échelle
759 @param style un style CSS
768 recopie la valeur du slider vers le spinbox
770 self.dbg.p(2, "rentre dans 'sync_slider2spinbox'")
777 Affiche l'image dont le numéro est dans self.pointage.spinBox_image et
780 self.dbg.p(2, "rentre dans 'sync_spinbox2others'")
788 affiche une trace au-dessus du self.job, qui reflète les positions
789 retenues pour l'échelle
791 self.dbg.p(2, "rentre dans 'feedbackEchelle'")
799 self.
echelle_modif.emit(self.tr(
"Refaire l'échelle"),
"background-color:orange;")
804 fonction appelée au début de l'état AB : prépare la sélection
805 des motifs à suivre en capture automatique
807 self.dbg.p(2, "rentre dans 'capture_auto'")
811 self.
zoomLabel.setText(self.tr(
"Zone à suivre n° {zone} x, y =").format(zone=self.
suivis[0]))
816 def suiviDuMotif(self):
817 self.
dbg.p(2,
"rentre dans 'suiviDuMotif'")
819 self.
dbg.p(3,
"selection des motifs finie")
826 self.
app.change_etat.emit(
"B")
834 méthode (re)lancée pour les détections automatiques de points
836 et relance un signal si la pile n'est pas vide après chacun
839 self.dbg.p(2, f"rentre dans 'detecteUnPoint', pileDeDetection = {self.pileDeDetections}")
843 self.
stop_n.emit(f
"STOP ({self.pileDeDetections.pop(0)})")
852 point = filter_picture(part, image, zone_proche)
854 echelle = self.
video.image_w / self.largeurFilm
858 echelle*(point[0]+part.shape[1]/2),
859 echelle*(point[1]+part.shape[0]/2)))
873 self.
app.change_etat.emit(
"D")
879 sont déjà bien réglés.
880 @param point la position à enregistrer
882 self.dbg.p(2, "rentre dans 'storePoint'")
888 def termine_pointage(self):
889 self.
dbg.p(2,
"rentre dans 'clic_sur_video'")
892 self.
app.sync_img2others(self.
index)
897 Fonction appelée en cas de pointage manuel sur l'image de la vidéo
898 après un mouserelease (bouton gauche)
901 self.
app.change_etat.emit(
"E")
911 self.
app.show_coord.emit()
916 Ajuste l'interface utilisateur pour attendre un nouveau clic
918 self.dbg.p(2, "rentre dans 'prepare_futur_clic'")
927 Change le texte du bouton STOP
928 @param text le nouveau texte
935 passage à l'objet suivant pour le pointage.
936 revient au premier objet quand on a fait le dernier, et
951 self.
app.change_etat.emit(
"D")
961 Renseigne sur le numéro d'objet du point attendu
962 affecte la ligne de statut et la ligne sous le zoom
963 @param obj l
'objet courant
965 self.dbg.p(2, "rentre dans 'affiche_point_attendu'")
966 self.
app.affiche_statut.emit(self.tr(
"Cliquez sur l'objet : {0}").format(obj))
971 Calcule les vecteurs vitesse affichables étant donné la collection
972 de points. Un vecteur vitesse a pour origine un point de la
973 trajectoire, et sa direction, sa norme sont basées sur le point
974 précédent et le point suivant ; il faut donc au moins trois pointages
975 pour que le résultat ne soit pas vide.
977 @param echelle_vitesse le nombre de pixels pour 1 m/s
978 @return un dictionnaire objet => [(org, ext), ...] où org et ext
979 sont l
'origine et l'extrémité d
'un vecteur vitesse
981 self.dbg.p(2, "rentre dans 'vecteursVitesse'")
982 result = {obj : []
for obj
in self.
suivis}
985 precedent = trajectoires[obj][0]
987 for i
in range(1, len(trajectoires[obj]) - 1):
993 point = trajectoires[obj][i]
994 suivant = trajectoires[obj][i+1]
998 result[obj].append ((point, point + (vitesse * echelle_vitesse)))
1004 Ici c'est la partie dévolue au pointageWidget quand on rouvre un
1005 fichier pymecavideox
1023 Met à jour les caches à cocher des axes
1024 @param x sens de l
'axe x (+1 ou -1)
1025 @param y sens de l
'axe y (+1 ou -1)
1027 self.dbg.p(2, "rentre dans 'coche_axes'")
1034 Rejoue les pointages issus d'un fichier pymecavideo
1035 @param data une liste de listes de type [t, x1, y1, ..., xn, yn]
1036 @param premiere_image_pointee la toute première image pointée
1040 for i
in range(len(data)) :
1043 if len(data[i]) > j:
1044 x, y = data[i][j:j + 2]
1053 index = i + premiere_image_pointee - 1)
1058 self.
index = der + 1
1064 self.
echelle_modif.emit(self.tr(
"Refaire l'échelle"),
"background-color:orange;")
1069 fonction de rappel déclenchée quand on clique dans la dernière
1071 @param qbbn le bouton qui a été cliqué pour en arriver là
1073 self.dbg.p(2, "rentre dans 'refait_point_depuis_tableau'")
1076 self.
index = qpbn.index_image
1078 self.
app.show_video.emit()
Un lecteur de vidéos qui permet d'extraire les images une par une.
dbg.py, a module for pymecavideo: a program to track moving points in a video frameset
Une classe qui permet de définir les états pour le pointageWidget debut, A, AB, B,...
def restaureEtat(self)
Restauration de l'état A ou D après (re)définition de l'échelle.
Une classe pour représenter les pointages : séquences éventuellement creuses, de quadruplets (date,...
def pointe(self, objet, position, index=None, date=None)
ajoute un pointage aux données ; on peut soit préciser l'index et la date s'en déduit,...
def pointEnMetre(self, p)
renvoie un point, dont les coordonnées sont en mètre, dans un référentiel "à l'endroit"
def refaire(self)
dépile un pointage de self.defaits et le rajoute à la fin de self.data
def premiere_image(self)
donne le numéro de la première image pointée (1 au minimum), ou None si aucun pointage n'est fait
def les_trajectoires(self)
renvoie un dictionnaire objet => trajectoire de l'objet
def purge_defaits(self)
purge les données à refaire si on vient de cliquer sur la vidéo pour un pointage
def derniere_image(self)
donne le numéro de la dernière image pointée (on compte à partir de 1), ou None si aucun pointage n'e...
def defaire(self)
retire le dernier pointage de self.data et l'empile dans self.defaits
def dimensionne(self, n_suivis, deltaT, n_images)
Crée les structures de données quand on en connaît par avance le nombre.
def csv_string(self, sep=";", unite="px", debut=1, origine=vecteur(0, 0))
renvoie self.data sous une forme acceptable (CSV)
def clearEchelle(self)
oublie la valeur de self.echelle_image
une classe pour des vecteurs 2D ; les coordonnées sont flottantes, et on peut accéder à celles-ci par...