From 637188e8f0dbb70f3da3f3e302cae787b4dabee6 Mon Sep 17 00:00:00 2001 From: s224361 <s224361@dtu.dk> Date: Mon, 20 Jan 2025 12:34:06 +0100 Subject: [PATCH] Cleaned up modules --- GUI_draft_NoEditorMode.py | 527 -------------------------- __pycache__/live_wire.cpython-310.pyc | Bin 0 -> 3870 bytes 2 files changed, 527 deletions(-) delete mode 100644 GUI_draft_NoEditorMode.py create mode 100644 __pycache__/live_wire.cpython-310.pyc diff --git a/GUI_draft_NoEditorMode.py b/GUI_draft_NoEditorMode.py deleted file mode 100644 index be3fc45..0000000 --- a/GUI_draft_NoEditorMode.py +++ /dev/null @@ -1,527 +0,0 @@ -import sys -import math -import numpy as np - -# NEW IMPORT -from scipy.signal import savgol_filter - -from PyQt5.QtWidgets import ( - QApplication, QMainWindow, QGraphicsView, QGraphicsScene, - QGraphicsEllipseItem, QGraphicsPixmapItem, QPushButton, - QHBoxLayout, QVBoxLayout, QWidget, QFileDialog, QGraphicsTextItem -) -from PyQt5.QtGui import QPixmap, QPen, QBrush, QColor, QFont -from PyQt5.QtCore import Qt, QRectF - -from live_wire import compute_cost_image, find_path - - -class LabeledPointItem(QGraphicsEllipseItem): - def __init__(self, x, y, label="", radius=4, color=Qt.red, removable=True, z_value=0, parent=None): - super().__init__(0, 0, 2*radius, 2*radius, parent) - self._x = x - self._y = y - self._r = radius - self._removable = removable - - pen = QPen(color) - brush = QBrush(color) - self.setPen(pen) - self.setBrush(brush) - self.setZValue(z_value) - - self._text_item = None - if label: - self._text_item = QGraphicsTextItem(self) - self._text_item.setPlainText(label) - self._text_item.setDefaultTextColor(QColor("black")) - font = QFont("Arial", 14) - font.setBold(True) - self._text_item.setFont(font) - self._scale_text_to_fit() - - self.set_pos(x, y) - - def _scale_text_to_fit(self): - if not self._text_item: - return - self._text_item.setScale(1.0) - circle_diam = 2 * self._r - raw_rect = self._text_item.boundingRect() - text_w = raw_rect.width() - text_h = raw_rect.height() - if text_w > circle_diam or text_h > circle_diam: - scale_factor = min(circle_diam / text_w, circle_diam / text_h) - self._text_item.setScale(scale_factor) - self._center_label() - - def _center_label(self): - if not self._text_item: - return - ellipse_w = 2 * self._r - ellipse_h = 2 * self._r - raw_rect = self._text_item.boundingRect() - scale_factor = self._text_item.scale() - scaled_w = raw_rect.width() * scale_factor - scaled_h = raw_rect.height() * scale_factor - tx = (ellipse_w - scaled_w) * 0.5 - ty = (ellipse_h - scaled_h) * 0.5 - self._text_item.setPos(tx, ty) - - def set_pos(self, x, y): - self._x = x - self._y = y - self.setPos(x - self._r, y - self._r) - - def get_pos(self): - return (self._x, self._y) - - def distance_to(self, x_other, y_other): - return math.sqrt((self._x - x_other)**2 + (self._y - y_other)**2) - - def is_removable(self): - return self._removable - - -class ImageGraphicsView(QGraphicsView): - def __init__(self, parent=None): - super().__init__(parent) - self.scene = QGraphicsScene(self) - self.setScene(self.scene) - - # Allow zoom around mouse pointer - self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) - - # Image display item - self.image_item = QGraphicsPixmapItem() - self.scene.addItem(self.image_item) - - # Parallel lists - self.anchor_points = [] # List[(x, y)] - self.point_items = [] # List[LabeledPointItem] - - self.dot_radius = 4 - self.path_radius = 1 - self.radius_cost_image = 2 # cost-lowering radius - self._img_w = 0 - self._img_h = 0 - - # For pan/drag - self.setDragMode(QGraphicsView.ScrollHandDrag) - self.viewport().setCursor(Qt.ArrowCursor) - - self._mouse_pressed = False - self._press_view_pos = None - self._drag_threshold = 5 - self._was_dragging = False - self._dragging_idx = None - self._drag_offset = (0, 0) - - # Keep original cost image to revert changes - self.cost_image_original = None - self.cost_image = None - - # The path is displayed as small magenta circles in self.full_path_points - self.full_path_points = [] - - # -------------------------------------------------------------------- - # LOADING - # -------------------------------------------------------------------- - def load_image(self, path): - pixmap = QPixmap(path) - if not pixmap.isNull(): - self.image_item.setPixmap(pixmap) - self.setSceneRect(QRectF(pixmap.rect())) - - self._img_w = pixmap.width() - self._img_h = pixmap.height() - - self._clear_all_points() - self.resetTransform() - self.fitInView(self.image_item, Qt.KeepAspectRatio) - - # Create S/E - s_x, s_y = 0.15*self._img_w, 0.5*self._img_h - e_x, e_y = 0.85*self._img_w, 0.5*self._img_h - - # S => not removable - self._insert_anchor_point(-1, s_x, s_y, label="S", removable=False, z_val=100, radius=6) - # E => not removable - self._insert_anchor_point(-1, e_x, e_y, label="E", removable=False, z_val=100, radius=6) - - # -------------------------------------------------------------------- - # ANCHOR POINTS - # -------------------------------------------------------------------- - def _insert_anchor_point(self, idx, x, y, label="", removable=True, z_val=0, radius=4): - """ - Insert at index=idx, or -1 => append just before E if E exists. - """ - if idx < 0: - # If we have at least 2 anchors, the last is E => insert before that - if len(self.anchor_points) >= 2: - idx = len(self.anchor_points) - 1 - else: - idx = len(self.anchor_points) - - self.anchor_points.insert(idx, (x, y)) - color = Qt.green if label in ("S","E") else Qt.red - item = LabeledPointItem(x, y, label=label, radius=radius, color=color, - removable=removable, z_value=z_val) - self.point_items.insert(idx, item) - self.scene.addItem(item) - - def _add_guide_point(self, x, y): - """ - User added a red guide point => lower cost, insert anchor, rebuild path. - """ - # 1) Revert cost - self._revert_cost_to_original() - # 2) Insert new anchor (removable) - self._insert_anchor_point(-1, x, y, label="", removable=True, z_val=1, radius=self.dot_radius) - # 3) Re-apply cost-lowering for all existing guide points - self._apply_all_guide_points_to_cost() - # 4) Rebuild path - self._rebuild_full_path() - - # -------------------------------------------------------------------- - # COST IMAGE - # -------------------------------------------------------------------- - def _revert_cost_to_original(self): - """self.cost_image <- copy of self.cost_image_original""" - if self.cost_image_original is not None: - self.cost_image = self.cost_image_original.copy() - - def _apply_all_guide_points_to_cost(self): - """Lower cost around every removable anchor.""" - if self.cost_image is None: - return - for i, (ax, ay) in enumerate(self.anchor_points): - if self.point_items[i].is_removable(): - self._lower_cost_in_circle(ax, ay, self.radius_cost_image) - - def _lower_cost_in_circle(self, x_f, y_f, radius): - """Set cost_image row,col in circle of radius -> global min.""" - if self.cost_image is None: - return - h, w = self.cost_image.shape - row_c = int(round(y_f)) - col_c = int(round(x_f)) - if not (0 <= row_c < h and 0 <= col_c < w): - return - global_min = self.cost_image.min() - r_s = max(0, row_c - radius) - r_e = min(h, row_c + radius + 1) - c_s = max(0, col_c - radius) - c_e = min(w, col_c + radius + 1) - for rr in range(r_s, r_e): - for cc in range(c_s, c_e): - dist = math.sqrt((rr - row_c)**2 + (cc - col_c)**2) - if dist <= radius: - self.cost_image[rr, cc] = global_min - - # -------------------------------------------------------------------- - # PATH BUILDING - # -------------------------------------------------------------------- - def _rebuild_full_path(self): - # Remove old path items - for item in self.full_path_points: - self.scene.removeItem(item) - self.full_path_points.clear() - - # Build subpaths - if len(self.anchor_points) < 2 or self.cost_image is None: - return - - big_xy = [] - for i in range(len(self.anchor_points)-1): - xA, yA = self.anchor_points[i] - xB, yB = self.anchor_points[i+1] - sub_xy = self._compute_subpath_xy(xA, yA, xB, yB) - if i == 0: - big_xy.extend(sub_xy) - else: - # avoid duplicating the point between subpaths - if len(sub_xy) > 1: - big_xy.extend(sub_xy[1:]) - - # --------------------------- - # NEW: Smooth the path - # --------------------------- - # big_xy is a list of (x, y). We'll convert to numpy and run savgol_filter - if len(big_xy) >= 7: - arr_xy = np.array(big_xy) # shape (N, 2) - # Apply Savitzky-Golay filter along axis=0 - # window_length=7, polyorder=1 - smoothed = savgol_filter(arr_xy, window_length=7, polyorder=1, axis=0) - # Convert back to list of (x, y) - big_xy = smoothed.tolist() - - # Draw them - for (px, py) in big_xy: - path_item = LabeledPointItem(px, py, label="", radius=self.path_radius, - color=Qt.magenta, removable=False, z_value=0) - self.full_path_points.append(path_item) - self.scene.addItem(path_item) - - # Ensure S/E stay on top - for p_item in self.point_items: - if p_item._text_item: - p_item.setZValue(100) - - def _compute_subpath_xy(self, xA, yA, xB, yB): - if self.cost_image is None: - return [] - h, w = self.cost_image.shape - rA, cA = int(round(yA)), int(round(xA)) - rB, cB = int(round(yB)), int(round(xB)) - rA = max(0, min(rA, h-1)) - cA = max(0, min(cA, w-1)) - rB = max(0, min(rB, h-1)) - cB = max(0, min(cB, w-1)) - try: - path_rc = find_path(self.cost_image, [(rA, cA), (rB, cB)]) - except ValueError as e: - print("Error in find_path:", e) - return [] - return [(c, r) for (r, c) in path_rc] - - # -------------------------------------------------------------------- - # MOUSE EVENTS (dragging, adding, removing points) - # -------------------------------------------------------------------- - def mousePressEvent(self, event): - if event.button() == Qt.LeftButton: - self._mouse_pressed = True - self._was_dragging = False - self._press_view_pos = event.pos() - - idx = self._find_item_near(event.pos(), 10) - if idx is not None: - # drag existing anchor - self._dragging_idx = idx - scene_pos = self.mapToScene(event.pos()) - px, py = self.point_items[idx].get_pos() - self._drag_offset = (scene_pos.x() - px, scene_pos.y() - py) - self.setDragMode(QGraphicsView.NoDrag) - self.viewport().setCursor(Qt.ClosedHandCursor) - return - else: - # If no anchor near, user might be panning - self.setDragMode(QGraphicsView.ScrollHandDrag) - self.viewport().setCursor(Qt.ClosedHandCursor) - - elif event.button() == Qt.RightButton: - self._remove_point_by_click(event.pos()) - - super().mousePressEvent(event) - - def mouseMoveEvent(self, event): - if self._dragging_idx is not None: - # dragging an anchor - scene_pos = self.mapToScene(event.pos()) - x_new = scene_pos.x() - self._drag_offset[0] - y_new = scene_pos.y() - self._drag_offset[1] - r = self.point_items[self._dragging_idx]._r - x_clamped = self._clamp(x_new, r, self._img_w - r) - y_clamped = self._clamp(y_new, r, self._img_h - r) - self.point_items[self._dragging_idx].set_pos(x_clamped, y_clamped) - return - else: - # if movement > threshold => pan - if self._mouse_pressed and (event.buttons() & Qt.LeftButton): - dist = (event.pos() - self._press_view_pos).manhattanLength() - if dist > self._drag_threshold: - self._was_dragging = True - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event): - super().mouseReleaseEvent(event) - if event.button() == Qt.LeftButton and self._mouse_pressed: - self._mouse_pressed = False - self.viewport().setCursor(Qt.ArrowCursor) - if self._dragging_idx is not None: - idx = self._dragging_idx - self._dragging_idx = None - self._drag_offset = (0, 0) - self.setDragMode(QGraphicsView.ScrollHandDrag) - - # update anchor_points - newX, newY = self.point_items[idx].get_pos() - # even if S/E => update coords - self.anchor_points[idx] = (newX, newY) - - # revert + re-apply cost, rebuild path - self._revert_cost_to_original() - self._apply_all_guide_points_to_cost() - self._rebuild_full_path() - - else: - if not self._was_dragging: - # user clicked an empty spot => add a guide point - scene_pos = self.mapToScene(event.pos()) - x, y = scene_pos.x(), scene_pos.y() - self._add_guide_point(x, y) - - self._was_dragging = False - - def _remove_point_by_click(self, view_pos): - idx = self._find_item_near(view_pos, threshold=10) - if idx is None: - return - # check if removable => skip S/E - if not self.point_items[idx].is_removable(): - return # do nothing - - # remove anchor - self.scene.removeItem(self.point_items[idx]) - self.point_items.pop(idx) - self.anchor_points.pop(idx) - - # revert + re-apply cost, rebuild path - self._revert_cost_to_original() - self._apply_all_guide_points_to_cost() - self._rebuild_full_path() - - def _find_item_near(self, view_pos, threshold=10): - scene_pos = self.mapToScene(view_pos) - x_click, y_click = scene_pos.x(), scene_pos.y() - min_dist = float('inf') - closest_idx = None - for i, itm in enumerate(self.point_items): - d = itm.distance_to(x_click, y_click) - if d < min_dist: - min_dist = d - closest_idx = i - if closest_idx is not None and min_dist <= threshold: - return closest_idx - return None - - # -------------------------------------------------------------------- - # ZOOM - # -------------------------------------------------------------------- - def wheelEvent(self, event): - """ - Zoom in/out with mouse wheel - """ - zoom_in_factor = 1.25 - zoom_out_factor = 1 / zoom_in_factor - - # If the user scrolls upward => zoom in. Otherwise => zoom out. - if event.angleDelta().y() > 0: - self.scale(zoom_in_factor, zoom_in_factor) - else: - self.scale(zoom_out_factor, zoom_out_factor) - event.accept() - - # -------------------------------------------------------------------- - # UTILS - # -------------------------------------------------------------------- - def _clamp(self, val, mn, mx): - return max(mn, min(val, mx)) - - def _clear_all_points(self): - for it in self.point_items: - self.scene.removeItem(it) - self.point_items.clear() - self.anchor_points.clear() - - for p in self.full_path_points: - self.scene.removeItem(p) - self.full_path_points.clear() - - def clear_guide_points(self): - """ - Removes all anchors that are 'removable' (guide points), - keeps S/E in place. Then reverts cost, re-applies, rebuilds path. - """ - i = 0 - while i < len(self.anchor_points): - if self.point_items[i].is_removable(): - self.scene.removeItem(self.point_items[i]) - del self.point_items[i] - del self.anchor_points[i] - else: - i += 1 - - for item in self.full_path_points: - self.scene.removeItem(item) - self.full_path_points.clear() - - self._revert_cost_to_original() - self._apply_all_guide_points_to_cost() - self._rebuild_full_path() - - -class MainWindow(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("Test GUI") - - main_widget = QWidget() - main_layout = QVBoxLayout(main_widget) - - self.image_view = ImageGraphicsView() - main_layout.addWidget(self.image_view) - - btn_layout = QHBoxLayout() - - self.btn_load_image = QPushButton("Load Image") - self.btn_load_image.clicked.connect(self.load_image) - btn_layout.addWidget(self.btn_load_image) - - self.btn_export_points = QPushButton("Export Points") - self.btn_export_points.clicked.connect(self.export_points) - btn_layout.addWidget(self.btn_export_points) - - self.btn_clear_points = QPushButton("Clear Points") - self.btn_clear_points.clicked.connect(self.clear_points) - btn_layout.addWidget(self.btn_clear_points) - - main_layout.addLayout(btn_layout) - self.setCentralWidget(main_widget) - self.resize(900, 600) - - def load_image(self): - options = QFileDialog.Options() - file_path, _ = QFileDialog.getOpenFileName( - self, "Open Image", "", - "Images (*.png *.jpg *.jpeg *.bmp *.tif)", - options=options - ) - if file_path: - self.image_view.load_image(file_path) - cost_img = compute_cost_image(file_path) - self.image_view.cost_image_original = cost_img - self.image_view.cost_image = cost_img.copy() - - def export_points(self): - if not self.image_view.anchor_points: - print("No anchor points to export.") - return - options = QFileDialog.Options() - file_path, _ = QFileDialog.getSaveFileName( - self, "Export Points", "", - "NumPy Files (*.npy);;All Files (*)", - options=options - ) - if file_path: - points_array = np.array(self.image_view.anchor_points) - np.save(file_path, points_array) - print(f"Exported {len(points_array)} points to {file_path}") - - def clear_points(self): - """Remove all removable anchors (guide points), keep S/E in place.""" - self.image_view.clear_guide_points() - - def closeEvent(self, event): - super().closeEvent(event) - - -def main(): - app = QApplication(sys.argv) - window = MainWindow() - window.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - main() diff --git a/__pycache__/live_wire.cpython-310.pyc b/__pycache__/live_wire.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7bee229c2a8aeac524b505befb6ca2b63083f625 GIT binary patch literal 3870 zcmZu!OK%)m6|QGL%I&xFP9_(GG;U#g7>i*MMKhDcA&NAZQIbKT<WZ;G_jY%s>y>+} zZMUhGMkErj$buEyWC2n(?2y<p8~#J>&>%%-1v`XBg74gFd+adP>Rac&&pr2i-#L}e z&Uyx(<-h)_fB(E;d_|ShkB!R5_^Usl;08A%!>`_^Z|b_`+xqRqu3s~$&x-1C!*6KW zj+(LOds=p)nYiV*wCqN+@ti-W<ytf!FZc_l(KPrK-sIkM(_iE>y!G7hm-tma$LG<q z%oq3~T2}ZqzQWI;<s5&9U*H!Zo#&VMW%OO(=0l@>{Rp}m+ikOKh=VLuBPmGk2P37z zAc5?5gCrS4SeEH17oB{7cYn|cqztIp>!!(G8tsWqenb{Hnn%$k>G)|usNsJHf7L^g z8!ycQ<rC|bHL-L1iBs6z<kkyo;^c1O6ehPJyLqi}pP7?dQJd7cGil`Y!s6}=bJENk zMZIY98n5%l3uodL-pl4Iqwp>oyt!;lW{Me{&D(zSi<Lj#``h93k6xR;*Nwug6NPb@ zzs9Ll*pGvwMQDRF)P@}u9*WXYL5#8&Mp4@;ojB#9Z0x3^ga>kb<j`QRw7Ywoel3ip z2)Ms+|0fUb-rDNidvNRL5AWRi<gT<Zs$3FJM?r)oI{^=}d=RQ!l1U`3Q7&~u*^NX; z@V@8_g-k>w$v~wY2M6s|>0|+{OlI?&JXBNV+%OLMqFm6g4y>NbK;?p$EfuF}J`lVU z#{IIX?~6p=+YJ*aPO_Y%^=s8;e)IUwc-iQtDyI>z4S5}7{xk00*!fHesdnOUf3&;v z(?o2^a8IyxwiP6CAcu-Q62k;~3HDin9;w1y<U0?CG;BAOeCLA=rbH~Do!ohtiEbD~ z!;w@YwF8~IVbbrYQ4UvAI}xUI_CqN)vN7zE#u@q8R3{vF6Dp|{6m#~R-Lf0DV>_m2 zE|?3pW6AHJqe})qe#nX^uJ%Jza-%SxncN)O(#zpu&&*$%g}Gx*$nmVgC@i?1&7Btx z+MEfT502w*AJL+RAAFjINv@b0q@#$lUBLnt;VN0$W1B3~oi}h&!C8=SmItyg@{LDr z8}5ZlUfLqz{(Nr~MPs_KM09aq=(Zz~l-|#RXe91RnM&zmyLOGRq%VW+xmK<3*J=HZ z?E1}WeTZe99E1diCg^*s3WjycW-m;5hlYHDs<vLsYM2cRk6Hb^ubsO5K6;O6=QAd( zqk;Y!tzSR@8qJX*y#j0neA!owi3#ii!G+PYatp?TISZ4JTo}*HE#vd=0msMkHQ?BI z=@f2Z)9hsf_?}7^j8_`ng*i0k&7xMASLi}8wrAZmo?d@?sj%oef-g<O@ED#u09{Vk zP+@`{91EI_*#00@g6)xA6$_ItMx!2ui3nsg)+_X68nYj;AZI`f>xu+E%JP)Gf6}C} z$&OLaR%O7$k!o*vdj6+@1V><!($)2oM^~q6-N5F#ZXl%}VI!g!wz?nke4u@0Aj1A2 zZ<8mH9u+Q`KE;NfSnbMguwIk~`5H^JJWP`yI<soUBkw9!jox6l$Bc_!Fp4lk37j!y ztCddc@3-IEoSxx<0QizK2dK2yRD?Y@w^%ZYvvH*mDX9iQh67Y<txc`o{uX7{iPYBj z_3?m<>R{E8wvTX)hWsuHaIVYqaVFYsX(d_d9txSN($$A8T^S@mfTNzuyj`DCS9L^r z4YSKyb#J9PF3p3|0Y|<K0*L@^e2%}mh@!FBvX(#{ExTpDV>ZkM%lg9l+NtSLXJXYX zMiV5o_x}R|B91w<<PyazAi=r>57&}SQqStaLvvu^iN(=USSyJ8SI`4ARIQkCsujLp zI6aDO6!naP-YyViBgz3W)ha+(9Vnn0W~0{lW^cqk(nIe)0i_`d(hl_%(u-=OMT2VC zWd<$YnSSp~e+!tKDO%Mj+V0`+^o4Gv`w0SJ776wkf#ETE(PPyO5V0~L$@Gsae<sq? z?b?U?pz>tOiY(}M5eUHa(YU(&sn4-hezLlbS8HrlFTp#+bMRW)hj%KjYXfzJpd*f( zklK6JA;EiQ0Wcx6Gzth7piVa*LE+LyJJ^@JRZ`d}Ylt$yp6}h?esuRi=N6)MdlthG z5?9gh&z?D0>1v3UwF;m9Tq5>6Jd8!6u#NKP-_joJtnG4eER3goeK0=ZYlw5Fj@Utc z;mj$~@_!Vr_o1bK10~gE6b<*PdEPz`?_EMDoHNOn8GbEG-bVKu{P8qCe)F%vSHJ$# zzyJQ>8NVh*r`$vP^^YJ_3_i`eC=}Y{RXPKMhr)m1mEdga0{I^@Fydt5;=@4S$U~iS z=zG`s$QqNQgx#T3kr4xchH^wZ7;TLPB_Kp33Ki+<{y4~pBvWuX#W1E<nNnX9`bo;Q z<qV2G{&@cRQQOhUl{|;qH`x>j;u1Abx>VLhrot#qBw0*eq2ekP?@&SM&8aZiI-PC& z9({_ooU2imfxjhxjOuH<ay#<9qyM5fV^v~`6Skl|3W|OnQVDzFAd(ps+7Zhj!+Pf7 zZ?*OAq*02f1LCJB&C*26t3gCL<roIh#sYobzW1PAnFhwvgA}Dxy%2p}TBNerW1SCl zQ$S7uo8OYItjctMjiw;vNVYm&V+T`gX^XU7zX?bnNx_=s%&F!4=5Ak3+ifg@^f!&( zHsz(=pgW}0ZrI7f0}-iCnjoh{8uMouMNvl9a_3CPl$4R2NjE6bI&sVeRP@IIsPmPD z4<7nNu?|<Y(eC2=Wa>fMYT9>_>~zrFZd>v_tU-zVo<QY3Dt=QRCqdYLTkjeA=8?J1 zmHvXEiZp3C&~qCC<`ZmWqmtL~q74W_a*Ntbd5>NfOD6|KRD9#=z*%Xr7zcS4rFj(Y zBE2OESzYzEvnU5u47HK8Tl@3V+6F#9D8JAyeySCj4^n3R1p(HoW^VLlkPUSDh|hp- zn2k5^VS_Y5e^2QASf_b9&}p8~UahY|pYfDMG&~p%R5otn%V!jc56SBl#p{NF{LyTk V`dOCuZsW4I*jV$fddscm{{WRM_S*me literal 0 HcmV?d00001 -- GitLab