OSCR

Two-step workflow integrating automatic registration and manual refinement for the accurate alignment of serial histological sections in 3D reconstruction.

Code ↔ Paper

1 match between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 1 match
  1. [1] § METHODS › Manual alignment using AlignRef › Overview and key features ↔ AlignRef.py, lines 211–356 · score 0.62 · Position Rotation, Finish Recording Position, Position Start, shortcuts, Graphical, translation

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,745 lines · 65 KB · Apache-2.0 · 1 match

  1. import sys
  2. import os
  3. import re
  4. from PyQt6.QtWidgets import (
  5. QApplication,
  6. QMainWindow,
  7. QFileDialog,
  8. QGraphicsView,
  9. QGraphicsScene,
  10. QGraphicsPixmapItem,
  11. QGraphicsPathItem,
  12. QGraphicsRectItem
  13. )
  14. from PyQt6.QtGui import (
  15. QImage,
  16. QPixmap,
  17. QColor,
  18. QPainterPath,
  19. QPen,
  20. QCursor
  21. )
  22. from PyQt6.QtCore import (
  23. Qt,
  24. QPointF,
  25. QRectF
  26. )
  27. from PyQt6.QtSvg import QSvgRenderer
  28. import numpy as np
  29. from ui_AlignRef import Ui_MainWindow
  30. from datetime import datetime
  31. import shutil
  32. from xml.etree import ElementTree as ET
  33. from collections import defaultdict
  34. from PyQt6.QtGui import QShortcut, QKeySequence
  35. from PIL import Image, ImageOps
  36. import pydicom
  37. import time
  38. import csv
  39. import cv2
  40. from pydicom.dataset import Dataset, FileDataset
  41. def extract_all_numbers(s):
  42. return [int(num) for num in re.findall(r'\d+', s)]
  43. class CustomGraphicsView(QGraphicsView):
  44. def __init__(self, parent=None):
  45. super().__init__(parent)
  46. self.setScene(QGraphicsScene(self))
  47. def wheelEvent(self, event):
  48. modifiers = QApplication.keyboardModifiers()
  49. delta = event.angleDelta().y()
  50. if modifiers == Qt.KeyboardModifier.ControlModifier:
  51. self.scale(1.25 if delta > 0 else 0.8, 1.25 if delta > 0 else 0.8)
  52. elif modifiers == Qt.KeyboardModifier.ShiftModifier:
  53. self.horizontalScrollBar().setValue(
  54. self.horizontalScrollBar().value() - delta
  55. )
  56. elif modifiers == Qt.KeyboardModifier.NoModifier:
  57. self.verticalScrollBar().setValue(
  58. self.verticalScrollBar().value() - delta
  59. )
  60. event.accept()
  61. class ResizableRectItem(QGraphicsRectItem):
  62. handle_size = 8 # ハンドル(リサイズ用小さい四角)のサイズ
  63. def __init__(self, rect, main_window=None):
  64. super().__init__(rect)
  65. self.main_window = main_window
  66. self.setFlags(
  67. QGraphicsRectItem.GraphicsItemFlag.ItemIsMovable |
  68. QGraphicsRectItem.GraphicsItemFlag.ItemIsSelectable |
  69. QGraphicsRectItem.GraphicsItemFlag.ItemSendsGeometryChanges
  70. )
  71. self.setPen(QPen(QColor('red'), 2, Qt.PenStyle.DashLine))
  72. # ハンドル位置(四隅 + 辺の中央 = 全8か所)
  73. self.handles = {
  74. 'top_left': QRectF(0, 0, self.handle_size, self.handle_size),
  75. 'top_right': QRectF(0, 0, self.handle_size, self.handle_size),
  76. 'bottom_left': QRectF(0, 0, self.handle_size, self.handle_size),
  77. 'bottom_right': QRectF(0, 0, self.handle_size, self.handle_size),
  78. 'top_mid': QRectF(0, 0, self.handle_size, self.handle_size),
  79. 'bottom_mid': QRectF(0, 0, self.handle_size, self.handle_size),
  80. 'left_mid': QRectF(0, 0, self.handle_size, self.handle_size),
  81. 'right_mid': QRectF(0, 0, self.handle_size, self.handle_size)
  82. }
  83. self.active_handle = None
  84. self.update_handles()
  85. def update_handles(self):
  86. rect = self.rect()
  87. self.handles['top_left'].moveTopLeft(rect.topLeft())
  88. self.handles['top_right'].moveTopRight(rect.topRight())
  89. self.handles['bottom_left'].moveBottomLeft(rect.bottomLeft())
  90. self.handles['bottom_right'].moveBottomRight(rect.bottomRight())
  91. self.handles['top_mid'].moveCenter(QPointF((rect.left() + rect.right()) / 2, rect.top()))
  92. self.handles['bottom_mid'].moveCenter(QPointF((rect.left() + rect.right()) / 2, rect.bottom()))
  93. self.handles['left_mid'].moveCenter(QPointF(rect.left(), (rect.top() + rect.bottom()) / 2))
  94. self.handles['right_mid'].moveCenter(QPointF(rect.right(), (rect.top() + rect.bottom()) / 2))
  95. def paint(self, painter, option, widget=None):
  96. super().paint(painter, option, widget)
  97. # ハンドルを描画
  98. painter.setBrush(QColor('white'))
  99. painter.setPen(QPen(QColor('black')))
  100. for handle in self.handles.values():
  101. painter.drawRect(handle)
  102. def mousePressEvent(self, event):
  103. pos = event.pos()
  104. self.active_handle = None
  105. for name, handle_rect in self.handles.items():
  106. if handle_rect.contains(pos):
  107. self.active_handle = name
  108. break
  109. super().mousePressEvent(event)
  110. def mouseReleaseEvent(self, event):
  111. self.active_handle = None
  112. super().mouseReleaseEvent(event) # ✅ 最初に!
  113. if self.main_window:
  114. self.main_window.crop_box_global_rect = self.rect()
  115. self.main_window.display_current_image()
  116. def mouseMoveEvent(self, event):
  117. if self.active_handle:
  118. self.resize_rect(event.pos())
  119. self.update_handles()
  120. else:
  121. super().mouseMoveEvent(event)
  122. def resize_rect(self, pos):
  123. rect = self.rect()
  124. if self.active_handle == 'top_left':
  125. rect.setTopLeft(pos)
  126. elif self.active_handle == 'top_right':
  127. rect.setTopRight(pos)
  128. elif self.active_handle == 'bottom_left':
  129. rect.setBottomLeft(pos)
  130. elif self.active_handle == 'bottom_right':
  131. rect.setBottomRight(pos)
  132. elif self.active_handle == 'top_mid':
  133. rect.setTop(pos.y())
  134. elif self.active_handle == 'bottom_mid':
  135. rect.setBottom(pos.y())
  136. elif self.active_handle == 'left_mid':
  137. rect.setLeft(pos.x())
  138. elif self.active_handle == 'right_mid':
  139. rect.setRight(pos.x())
  140. # ✅ これを追加!
  141. self.prepareGeometryChange()
  142. self.setRect(rect.normalized())
  143. class Main(QMainWindow, Ui_MainWindow):
  144. def __init__(self):
  145. super().__init__()
  146. self.setupUi(self)
  147. self.combo_canvas_bg.currentTextChanged.connect(lambda color: setattr(self, 'canvas_bg_color', color))
  148. self.canvas_bg_color = self.combo_canvas_bg.currentText()
  149. self.crop_box_coords = None # (x1, y1, x2, y2)
  150. self.original_images_backup = {} # Undo用に元画像をバックアップ
  151. self.box_mode = False
  152. self.box_points = []
  153. self.temp_box_item = None
  154. self.last_box_prompt = None
  155. self.stored_boxes = []
  156. self.crop_mode = False
  157. self.crop_box_points = []
  158. self.crop_box_coords = None
  159. self.current_crop_rect_item = None
  160. self.btn_start_crop.clicked.connect(self.start_crop_mode)
  161. self.btn_clear_crop_box.clicked.connect(self.clear_crop_box)
  162. self.btn_apply_crop.clicked.connect(self.apply_crop)
  163. self.btn_undo_crop.clicked.connect(self.undo_crop)
  164. self.btn_export_aligned.clicked.connect(self.export_aligned_images)
  165. self.resized_images_cache = {} #画像読み込み・キャンバス調整済み画像を格納する辞書 初期化
  166. self.image_paths = {}
  167. self.image_sizes = {}
  168. self.canvas_width = 0
  169. self.canvas_height = 0
  170. self.recording_position = False
  171. self.initial_transform = None # QTransform
  172. self.current_translation = QPointF(0, 0)
  173. self.current_rotation_deg = 0.0
  174. self.image_pristine = True
  175. self.ignore_spinbox_change = False
  176. # 一括トラッキング用
  177. self.batch_object_data = [] # 各オブジェクトの情報を辞書形式で保持
  178. self.box_per_frame = {} # 例: {0: ((x1,y1), (x2,y2)), 1: ((x1,y1), (x2,y2)), ...}
  179. self.installEventFilter(self)
  180. # ✅ graphicsView を CustomGraphicsView に差し替え
  181. layout = self.central_widget.layout()
  182. index = layout.indexOf(self.graphicsView)
  183. layout.removeWidget(self.graphicsView)
  184. self.graphicsView.deleteLater()
  185. self.graphicsView = CustomGraphicsView()
  186. layout.insertWidget(index, self.graphicsView)
  187. # ✅ Scene を作成
  188. self.scene = QGraphicsScene()
  189. self.graphicsView.setScene(self.scene)
  190. self.btn_load_images.clicked.connect(self.load_image_folder)
  191. self.btn_expand_canvas.clicked.connect(self.expand_canvas_and_reload)
  192. self.btn_fit_to_window.clicked.connect(self.fit_view_to_window)
  193. self.btn_prev_image.clicked.connect(self.overlay_previous_image)
  194. self.btn_next_image.clicked.connect(self.overlay_next_image)
  195. self.btn_clear_overlay.clicked.connect(self.clear_overlay)
  196. self.btn_start_record_position.clicked.connect(self.start_recording_position)
  197. self.btn_end_record_position.clicked.connect(self.finish_recording_position)
  198. self.btn_cancel_record_position.clicked.connect(self.cancel_recording_position)
  199. self.btn_set_pos_start.clicked.connect(self.set_position_start)
  200. self.btn_set_pos_end.clicked.connect(self.set_position_end)
  201. self.btn_apply_pos_rotation.clicked.connect(self.apply_recorded_position_to_range)
  202. self.btn_cancel_apply_pos.clicked.connect(self.cancel_applied_position)
  203. #Undo Redoのための変数
  204. self.undo_stack = {} # 例: {'0001': [svg_text_before_edit, ...]}
  205. # ✅ 状態保持
  206. self.image_paths = {}
  207. self.mask_paths = {}
  208. self.current_index = 0
  209. self.graphicsView.viewport().installEventFilter(self)
  210. self.modified_svg_trees = {}
  211. self.path_elements_by_color = {}
  212. self.pixmap_cache = {} # 画像キャッシュ
  213. self.svg_renderer_cache = {} # SVGレンダリングキャッシュ
  214. now = datetime.now().strftime("%Y%m%d_%H%M%S")
  215. # self.output_mask_dir = os.path.join(os.getcwd(), f"masks_{now}")
  216. # os.makedirs(self.output_mask_dir, exist_ok=True)
  217. self.default_output_dir = os.getcwd()
  218. self.redo_stack = defaultdict(list) # 🔁 Redoline用のスタック(画像ごと)
  219. self.calibration_mode = False
  220. self.calibration_points = []
  221. # self.mm_per_px = 1.0 # 初期値:1px = 1mm
  222. # self.z_spacing_mm = 1.0
  223. self.mm_per_px = None
  224. self.z_spacing_mm = None
  225. # self.interpolation_factor = 2 # Z方向の線形補完の倍率
  226. # 🔽 マウス移動を検知するために必要
  227. self.graphicsView.setMouseTracking(True)
  228. self.graphicsView.viewport().setMouseTracking(True)
  229. # 🔽 キャリブレーション用初期化
  230. self.temp_line_item = None
  231. self.calibration_points = []
  232. undo_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self)
  233. undo_shortcut.activated.connect(self.smart_undo)
  234. self.label_status.setText("Ready.")
  235. self.loaded_images = {} # 🔧 画像読み込み管理用の辞書
  236. self.overlay_image_item = None # ← 重ねる画像表示用
  237. self.crop_box_per_frame = {} # ← 画像キーごとの赤枠情報を保持
  238. self.crop_box_global_rect = None # ✅ これを追加!
  239. self.expand_count = 0
  240. def load_image_folder(self):
  241. import pathlib
  242. folder = QFileDialog.getExistingDirectory(self, "Select Image Folder")
  243. if not folder:
  244. return
  245. input_folder = pathlib.Path(folder)
  246. self.input_folder_name = input_folder.name
  247. # ✅ Canvas 背景色
  248. canvas_color_raw = self.combo_canvas_bg.currentText()
  249. print(f"[DEBUG] selected_color raw: {canvas_color_raw}")
  250. canvas_color = canvas_color_raw.lower()
  251. fill_rgb = (255, 255, 255) if canvas_color == "white" else (0, 0, 0)
  252. input_folder = pathlib.Path(folder)
  253. jpg_folder = pathlib.Path(os.getcwd()) / f"{input_folder.name}jpg"
  254. valid_exts = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".dcm"}
  255. self.image_paths = {}
  256. self.image_sizes = {}
  257. self.resized_images_cache = {}
  258. max_width = 0
  259. max_height = 0
  260. # 1st pass: サイズ確認
  261. for filename in sorted(os.listdir(folder)):
  262. ext = pathlib.Path(filename).suffix.lower()
  263. if ext not in valid_exts:
  264. continue
  265. input_path = os.path.join(folder, filename)
  266. try:
  267. if ext == ".dcm":
  268. ds = pydicom.dcmread(input_path)
  269. arr = ds.pixel_array
  270. arr = self._normalize_grayscale(arr)
  271. image = Image.fromarray(arr).convert("RGB")
  272. else:
  273. image = Image.open(input_path).convert("RGB")
  274. max_width = max(max_width, image.width)
  275. max_height = max(max_height, image.height)
  276. except Exception as e:
  277. print(f"[WARN] Failed to process {filename} during size check: {e}")
  278. self.canvas_width = max_width
  279. self.canvas_height = max_height
  280. # 2nd pass: パディング → 保存
  281. for i, filename in enumerate(sorted(os.listdir(folder))):
  282. ext = pathlib.Path(filename).suffix.lower()
  283. if ext not in valid_exts:
  284. continue
  285. input_path = os.path.join(folder, filename)
  286. key = f"{i+1:04}"
  287. output_jpg_path = os.path.join(jpg_folder, f"image{key}.jpg")
  288. if not jpg_folder.exists():
  289. jpg_folder.mkdir(exist_ok=True)
  290. try:
  291. if ext == ".dcm":
  292. ds = pydicom.dcmread(input_path)
  293. arr = ds.pixel_array
  294. arr = self._normalize_grayscale(arr)
  295. image = Image.fromarray(arr).convert("RGB")
  296. else:
  297. image = Image.open(input_path).convert("RGB")
  298. delta_w = max_width - image.width
  299. delta_h = max_height - image.height
  300. padding = (
  301. delta_w // 2,
  302. delta_h // 2,
  303. delta_w - delta_w // 2,
  304. delta_h - delta_h // 2,
  305. )
  306. image_padded = ImageOps.expand(image, padding, fill=fill_rgb)
  307. self.resized_images_cache[key] = image_padded
  308. image_padded.save(output_jpg_path, "JPEG")
  309. self.image_paths[key] = output_jpg_path
  310. self.image_sizes[key] = image_padded.size
  311. except Exception as e:
  312. print(f"[WARN] Failed to process {filename}: {e}")
  313. self.label_status.setText(f"Loaded {len(self.image_paths)} images (converted to JPG, unified canvas).")
  314. self.current_index = 0
  315. # ✅ DICOM 情報保存(必要な場合のみ)
  316. dcm_paths = [os.path.join(folder, f) for f in sorted(os.listdir(folder)) if f.lower().endswith(".dcm")]
  317. if dcm_paths:
  318. try:
  319. ds = pydicom.dcmread(dcm_paths[0])
  320. width = int(getattr(ds, "Columns", 0))
  321. height = int(getattr(ds, "Rows", 0))
  322. depth = len(dcm_paths)
  323. pixel_spacing = getattr(ds, "PixelSpacing", ["", ""])
  324. slice_thickness = getattr(ds, "SliceThickness", "")
  325. image_position = getattr(ds, "ImagePositionPatient", ["", "", ""])
  326. volume_table = [
  327. ["Width", "Height", "Depth"],
  328. [str(width), str(height), str(depth)],
  329. ["X Spacing", "Y Spacing", "Z Spacing"],
  330. [str(pixel_spacing[0]), str(pixel_spacing[1]), str(slice_thickness)],
  331. ["X Origin", "Y Origin", "Z Origin"],
  332. [str(image_position[0]), str(image_position[1]), str(image_position[2])]
  333. ]
  334. self.mm_per_px = float(pixel_spacing[0]) if pixel_spacing[0] else None
  335. self.z_spacing_mm = float(slice_thickness) if slice_thickness else None
  336. csv_filename = f"{input_folder.name}_volinf.csv"
  337. csv_path = os.path.join(os.getcwd(), csv_filename)
  338. with open(csv_path, "w", newline="", encoding="utf-8") as f:
  339. writer = csv.writer(f)
  340. writer.writerows(volume_table)
  341. print(f"[INFO] Volume info saved to: {csv_path}")
  342. except Exception as e:
  343. print(f"[WARN] Failed to extract volume info: {e}")
  344. self.image_pristine = True
  345. # ✅ 元画像の保持(ここが今回の追加ポイント)
  346. self.original_images = {}
  347. for key, path in self.image_paths.items():
  348. self.original_images[key] = Image.open(path).convert("RGB")
  349. self.display_current_image()
  350. # self.label_status.setText("✅ Images loaded.")
  351. self.fit_view_to_window()
  352. self.label_status.setText("✅ Images loaded. Use ↓↑, F/R, or J/U to switch images.")
  353. # ✅ 位置合わせ済み一時ファイルの記録を初期化
  354. self.transformed_image_paths = {}
  355. def expand_canvas_and_reload(self):
  356. from PIL import Image
  357. import os
  358. if not self.image_paths:
  359. self.label_status.setText("⚠ No images loaded.")
  360. return
  361. QApplication.processEvents() # 🔸 ComboBoxの選択反映を強制
  362. # 🔹 UIから背景色取得("White" or "Black")
  363. selected_color = self.combo_canvas_bg.currentText().lower()
  364. print(f"[DEBUG] selected_color raw: {self.combo_canvas_bg.currentText()}")
  365. color_map = {
  366. "white": (255, 255, 255),
  367. "black": (0, 0, 0)
  368. }
  369. fill_rgb = color_map.get(selected_color, (255, 255, 255)) # デフォルト白
  370. print(f"[DEBUG] fill_rgb = {fill_rgb}")
  371. # 出力ディレクトリ作成
  372. expanded_dir = os.path.join(os.getcwd(), "expanded_canvas")
  373. os.makedirs(expanded_dir, exist_ok=True)
  374. print(f"[DEBUG] Output folder created at: {expanded_dir}")
  375. new_image_paths = {}
  376. new_image_sizes = {}
  377. new_cache = {}
  378. for key, path in self.image_paths.items():
  379. print(f"[DEBUG] Processing image key = {key}, path = {path}")
  380. img = Image.open(path).convert("RGB")
  381. new_width = img.width + 200
  382. new_height = img.height + 200
  383. print(f"[DEBUG] New canvas size: {new_width}x{new_height}")
  384. # 指定背景色のキャンバスを生成し、中央に貼り付け
  385. background = Image.new("RGB", (new_width, new_height), fill_rgb)
  386. background.paste(img, (100, 100)) # 上下左右 100px
  387. new_path = os.path.join(expanded_dir, f"expanded_{key}.jpg")
  388. background.save(new_path)
  389. print(f"[DEBUG] Saved expanded image to: {new_path}")
  390. new_image_paths[key] = new_path
  391. new_image_sizes[key] = background.size
  392. new_cache[key] = background
  393. # データ更新
  394. self.image_paths = new_image_paths
  395. self.image_sizes = new_image_sizes
  396. self.resized_images_cache = new_cache
  397. self.canvas_width += 200
  398. self.canvas_height += 200
  399. print(f"[DEBUG] Canvas updated: width={self.canvas_width}, height={self.canvas_height}")
  400. self.display_current_image()
  401. self.label_status.setText("✅ Canvas expanded by 100px on all sides.")
  402. self.expand_count += 1
  403. self.fit_view_to_window()
  404. def fit_view_to_window(self):
  405. self.graphicsView.fitInView(self.scene.itemsBoundingRect(), Qt.AspectRatioMode.KeepAspectRatio)
  406. self.label_status.setText("✅ View fitted to window.")
  407. def overlay_previous_image(self):
  408. # 既存のオーバーレイがあれば削除
  409. if self.overlay_image_item:
  410. self.scene.removeItem(self.overlay_image_item)
  411. self.overlay_image_item = None
  412. # 前の画像が存在するかチェック
  413. prev_index = self.current_index - 1
  414. if prev_index < 0:
  415. self.label_status.setText("⚠ No previous image.")
  416. return
  417. prev_key = f"{prev_index+1:04}"
  418. if prev_key not in self.image_paths:
  419. self.label_status.setText("⚠ Previous image not found.")
  420. return
  421. # 画像読み込みと変換
  422. img = Image.open(self.image_paths[prev_key]).convert("RGBA")
  423. data = img.tobytes("raw", "RGBA")
  424. qimg = QImage(data, img.width, img.height, QImage.Format.Format_RGBA8888)
  425. pixmap = QPixmap.fromImage(qimg)
  426. # シーンにオーバーレイとして追加
  427. self.overlay_image_item = self.scene.addPixmap(pixmap)
  428. self.overlay_image_item.setZValue(10)
  429. self.overlay_image_item.setOpacity(0.5) # 半透明
  430. self.label_status.setText("🟡 Previous image overlayed.")
  431. def overlay_next_image(self):
  432. # 先に既存のオーバーレイを削除
  433. if self.overlay_image_item:
  434. self.scene.removeItem(self.overlay_image_item)
  435. self.overlay_image_item = None
  436. # ✅ 次のインデックスとキーを定義
  437. next_index = self.current_index + 1
  438. next_key = f"{next_index+1:04}"
  439. if next_key not in self.image_paths:
  440. self.label_status.setText("⚠ Next image not found.")
  441. return
  442. # 画像読み込み
  443. img = Image.open(self.image_paths[next_key]).convert("RGBA")
  444. data = img.tobytes("raw", "RGBA")
  445. qimg = QImage(data, img.width, img.height, QImage.Format.Format_RGBA8888)
  446. pixmap = QPixmap.fromImage(qimg)
  447. self.overlay_image_item = self.scene.addPixmap(pixmap)
  448. self.overlay_image_item.setZValue(10)
  449. self.overlay_image_item.setOpacity(0.5)
  450. self.label_status.setText("🟢 Next image overlayed.")
  451. def clear_overlay(self):
  452. if self.overlay_image_item:
  453. self.scene.removeItem(self.overlay_image_item)
  454. self.overlay_image_item = None
  455. self.label_status.setText("Overlay cleared.")
  456. def start_recording_position(self):
  457. # すでに存在する仮画像があれば削除
  458. if hasattr(self, 'position_preview_item') and self.position_preview_item:
  459. # self.scene.removeItem(self.position_preview_item)
  460. if hasattr(self, 'position_preview_item') and self.position_preview_item:
  461. try:
  462. if self.position_preview_item.scene(): # まだ生きているなら
  463. self.scene.removeItem(self.position_preview_item)
  464. except RuntimeError:
  465. print("[WARN] position_preview_item already deleted")
  466. self.position_preview_item = None
  467. self.position_preview_item = None
  468. # 現在の画像を仮画像として複製
  469. key = f"{self.current_index+1:04}"
  470. img_path = self.image_paths.get(key)
  471. if not img_path:
  472. self.label_status.setText("⚠ No image loaded.")
  473. return
  474. img = Image.open(img_path).convert("RGBA")
  475. qimg = QImage(img.tobytes("raw", "RGBA"), img.width, img.height, QImage.Format.Format_RGBA8888)
  476. pixmap = QPixmap.fromImage(qimg)
  477. self.position_preview_item = self.scene.addPixmap(pixmap)
  478. self.position_preview_item.setZValue(5)
  479. self.position_preview_item.setOpacity(1.0) # 少し透過でもOK
  480. # self.position_preview_item.setTransformOriginPoint(pixmap.rect().center())
  481. center_point = QPointF(pixmap.rect().center())
  482. self.position_preview_item.setTransformOriginPoint(center_point)
  483. # 移動・回転ログの初期化
  484. self.position_dx = 0
  485. self.position_dy = 0
  486. self.position_angle = 0.0
  487. # self.label_status.setText("🔴 Position recording started.")
  488. # 🔽 ステータス表示(ショートカット説明付き)
  489. self.label_status.setText(
  490. "🔴 Position recording started. ⬆⬇⬅➡ / WASD / OKL;: Move | Q/I: Rotate Left | E/P: Rotate Right"
  491. )
  492. def translate_preview(self, dx, dy):
  493. if hasattr(self, 'position_preview_item') and self.position_preview_item:
  494. self.position_preview_item.moveBy(dx, dy)
  495. self.position_dx += dx
  496. self.position_dy += dy
  497. def rotate_preview(self, angle_deg):
  498. if hasattr(self, 'position_preview_item') and self.position_preview_item:
  499. self.position_preview_item.setRotation(
  500. self.position_preview_item.rotation() + angle_deg
  501. )
  502. self.position_angle += angle_deg
  503. def finish_recording_position(self):
  504. if not hasattr(self, 'position_preview_item') or self.position_preview_item is None:
  505. self.label_status.setText("⚠ No preview to record.")
  506. return
  507. # 記録値を保存
  508. self.recorded_position_transform = {
  509. 'dx': self.position_dx,
  510. 'dy': self.position_dy,
  511. 'angle_deg': self.position_angle
  512. }
  513. # プレビュー削除
  514. self.scene.removeItem(self.position_preview_item)
  515. self.position_preview_item = None
  516. self.clear_overlay() # 🔸ここを追加**
  517. self.label_status.setText(
  518. f"✅ Recorded Δx={self.position_dx}, Δy={self.position_dy}, Δθ={self.position_angle:.1f}°"
  519. )
  520. def cancel_recording_position(self):
  521. if hasattr(self, 'position_preview_item') and self.position_preview_item:
  522. self.scene.removeItem(self.position_preview_item)
  523. self.position_preview_item = None
  524. self.label_status.setText("❌ Position recording canceled.")
  525. else:
  526. self.label_status.setText("ℹ️ No active position preview to cancel.")
  527. def set_position_start(self):
  528. self.position_range_start_index = self.current_index
  529. print(f"[DEBUG] Set Position Start: frame {self.position_range_start_index + 1}")
  530. self.label_status.setText(f"📍 Position Start set at frame {self.position_range_start_index + 1}")
  531. def set_position_end(self):
  532. self.position_range_end_index = self.current_index
  533. print(f"[DEBUG] Set Position Start: frame {self.position_range_start_index + 1}")
  534. self.label_status.setText(f"📍 Position End set at frame {self.position_range_end_index + 1}")
  535. def apply_transform(self, img_path, dx, dy, angle_deg, canvas_size):
  536. img = Image.open(img_path).convert("RGBA")
  537. img_rotated = img.rotate(-angle_deg, resample=Image.BICUBIC, expand=True) # ← ここを修正
  538. bg = Image.new("RGBA", canvas_size, (255, 255, 255, 0))
  539. cx = (canvas_size[0] - img_rotated.width) // 2 + dx
  540. cy = (canvas_size[1] - img_rotated.height) // 2 + dy
  541. bg.paste(img_rotated, (cx, cy), img_rotated)
  542. return bg.convert("RGB")
  543. def apply_recorded_position_to_range(self):
  544. # 🔹 apply前に状態をバックアップ
  545. self.aligned_image_paths_backup = self.image_paths.copy()
  546. self.aligned_image_sizes_backup = self.image_sizes.copy()
  547. self.aligned_cache_backup = self.resized_images_cache.copy()
  548. if not hasattr(self, 'recorded_position_transform'):
  549. self.label_status.setText("⚠ No recorded position to apply.")
  550. return
  551. if not hasattr(self, 'position_range_start_index') or not hasattr(self, 'position_range_end_index'):
  552. self.label_status.setText("⚠ Position start/end not set.")
  553. return
  554. dx = self.recorded_position_transform['dx']
  555. dy = self.recorded_position_transform['dy']
  556. angle_deg = self.recorded_position_transform['angle_deg']
  557. canvas_size = (self.canvas_width, self.canvas_height)
  558. output_dir = os.path.join(os.getcwd(), "position_applied")
  559. os.makedirs(output_dir, exist_ok=True)
  560. start = min(self.position_range_start_index, self.position_range_end_index)
  561. end = max(self.position_range_start_index, self.position_range_end_index)
  562. for i in range(start, end + 1):
  563. key = f"{i+1:04}"
  564. if key not in self.image_paths:
  565. continue
  566. input_path = self.image_paths[key]
  567. result = self.apply_transform(input_path, dx, dy, angle_deg, canvas_size)
  568. output_path = os.path.join(output_dir, f"posapplied_{key}.jpg")
  569. result.save(output_path)
  570. self.image_paths[key] = output_path
  571. self.resized_images_cache[key] = result
  572. self.image_sizes[key] = result.size
  573. self.display_current_image()
  574. self.transformed_images = self.resized_images_cache.copy()
  575. self.label_status.setText(f"✅ Applied transform to frames {start+1} to {end+1}")
  576. def cancel_applied_position(self):
  577. if not hasattr(self, "aligned_image_paths_backup"):
  578. self.label_status.setText("⚠ No applied result to cancel.")
  579. return
  580. # 元の状態に復元
  581. self.image_paths = self.aligned_image_paths_backup.copy()
  582. self.image_sizes = self.aligned_image_sizes_backup.copy()
  583. self.resized_images_cache = self.aligned_cache_backup.copy()
  584. del self.aligned_image_paths_backup
  585. del self.aligned_image_sizes_backup
  586. del self.aligned_cache_backup
  587. self.label_status.setText("⛔ Applied position canceled.")
  588. self.display_current_image()
  589. def start_crop_mode(self):
  590. # クロップモードとして box_mode を再利用
  591. self.box_mode = True
  592. self.crop_box_points = [] # ✅ 統一された変数名を使用
  593. print("[DEBUG] start_crop_mode called")
  594. # クロスヘア仮線の初期化
  595. self.temp_crosshair_hline = None
  596. self.temp_crosshair_vline = None
  597. # 仮のボックスがあれば削除
  598. if hasattr(self, "temp_box_item") and self.temp_box_item:
  599. self.scene.removeItem(self.temp_box_item)
  600. self.temp_box_item = None
  601. # クロップ用ボックスも削除
  602. if hasattr(self, "current_crop_rect_item"):
  603. try:
  604. if self.current_crop_rect_item is not None and self.current_crop_rect_item.scene() is not None:
  605. self.scene.removeItem(self.current_crop_rect_item)
  606. except RuntimeError:
  607. print("[WARN] current_crop_rect_item already deleted.")
  608. self.current_crop_rect_item = None
  609. # セグメント系の残骸があればクリア(今後不要なら削除してもOK)
  610. self.last_box_prompt = None
  611. self.last_used_box_px = None
  612. self.box_per_frame.clear()
  613. self.label_status.setText("Click top-left and bottom-right corners to set crop box.")
  614. def clear_crop_box(self):
  615. # 現在のクロップボックス表示を削除
  616. if hasattr(self, "current_crop_rect_item") and self.current_crop_rect_item:
  617. try:
  618. if self.current_crop_rect_item.scene():
  619. self.scene.removeItem(self.current_crop_rect_item)
  620. except RuntimeError:
  621. print("[WARN] current_crop_rect_item already deleted.")
  622. self.current_crop_rect_item = None
  623. # 仮ボックスも削除
  624. if hasattr(self, "temp_box_item") and self.temp_box_item:
  625. self.scene.removeItem(self.temp_box_item)
  626. self.temp_box_item = None
  627. # 内部状態を初期化
  628. self.crop_box_coords = None
  629. self.crop_box_points = []
  630. self.box_per_frame.clear() # 必要に応じて残してもOK
  631. key = self.get_current_image_key()
  632. if key in self.crop_box_per_frame:
  633. del self.crop_box_per_frame[key]
  634. # ✅ 全画像共通のクロップ枠もクリア
  635. self.crop_box_global_rect = None
  636. self.label_status.setText("Crop box cleared.")
  637. def apply_crop(self):
  638. if not self.current_crop_rect_item:
  639. self.label_status.setText("⚠ No crop box set.")
  640. return
  641. # 🔸 scene座標から矩形取得
  642. scene_rect = self.current_crop_rect_item.sceneBoundingRect()
  643. x1_scene = int(scene_rect.left())
  644. y1_scene = int(scene_rect.top())
  645. x2_scene = int(scene_rect.right())
  646. y2_scene = int(scene_rect.bottom())
  647. # 🔸 どの画像がクロップ対象か決定(transformed → processed → original)
  648. if hasattr(self, "transformed_images") and self.transformed_images:
  649. source_images = self.transformed_images
  650. elif hasattr(self, "processed_images") and self.processed_images:
  651. source_images = self.processed_images
  652. else:
  653. source_images = self.original_images
  654. # 🔸 expand補正:originalを使ってるときだけ補正が必要
  655. if source_images is self.original_images:
  656. expand_px = 100 * self.expand_count
  657. x1_scene -= expand_px
  658. x2_scene -= expand_px
  659. y1_scene -= expand_px
  660. y2_scene -= expand_px
  661. box = (min(x1_scene, x2_scene), min(y1_scene, y2_scene), max(x1_scene, x2_scene), max(y1_scene, y2_scene))
  662. # 🔸 仮画像を新たに生成
  663. self.processed_images = {}
  664. for key, img in source_images.items():
  665. img_w, img_h = img.size
  666. crop_box = (
  667. max(0, min(box[0], img_w)),
  668. max(0, min(box[1], img_h)),
  669. max(0, min(box[2], img_w)),
  670. max(0, min(box[3], img_h)),
  671. )
  672. cropped = img.crop(crop_box)
  673. self.processed_images[key] = cropped
  674. self.image_sizes = {k: v.size for k, v in self.processed_images.items()}
  675. self.canvas_width, self.canvas_height = self.processed_images[self.get_current_image_key()].size
  676. self.display_current_image()
  677. self.label_status.setText("✅ Cropped working images (originals untouched).")
  678. # ✅ クロップ枠(赤枠)をシーンから削除
  679. if self.current_crop_rect_item and self.current_crop_rect_item.scene():
  680. self.scene.removeItem(self.current_crop_rect_item)
  681. self.current_crop_rect_item = None
  682. # ✅ 全画像共通の赤枠もリセット
  683. self.crop_box_global_rect = None
  684. # ✅ ボックス点もクリア
  685. self.box_points = []
  686. self.expand_count = 0 # クロップ後に expand オフセットをリセット(任意)
  687. self.transformed_images = None
  688. print(f"[DEBUG] Using {'original' if source_images is self.original_images else 'processed'} images for crop")
  689. print(f"[DEBUG] Cropping box: {box}")
  690. def undo_crop(self):
  691. if not self.original_images_backup:
  692. self.label_status.setText("⚠ Nothing to undo.")
  693. return
  694. for key, img in self.original_images_backup.items():
  695. img.save(self.image_paths[key]) # 元の画像に戻す
  696. self.scene.clear()
  697. self.display_current_image()
  698. self.original_images_backup.clear()
  699. self.label_status.setText("✅ Crop undone, original images restored.")
  700. def export_aligned_images(self):
  701. if not hasattr(self, "resized_images_cache") or not self.resized_images_cache:
  702. self.label_status.setText("⚠ No aligned images to export.")
  703. return
  704. # 出力形式取得
  705. fmt = self.combo_export_format.currentText().lower()
  706. extension = "dcm" if fmt == "dcm" else fmt.lower()
  707. # PILが受け入れる形式に変換
  708. format_map = {
  709. "jpg": "JPEG",
  710. "jpeg": "JPEG",
  711. "png": "PNG",
  712. "bmp": "BMP",
  713. "tiff": "TIFF"
  714. }
  715. save_format = format_map.get(fmt, fmt.upper())
  716. # 元フォルダ名とタイムスタンプで出力先を構成
  717. # timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
  718. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  719. input_folder_name = getattr(self, "input_folder_name", "aligned") # 事前に保存しておくと便利
  720. export_dir_name = f"{input_folder_name}_aligned_{timestamp}"
  721. target_dir = os.path.join(os.getcwd(), export_dir_name)
  722. os.makedirs(target_dir, exist_ok=True)
  723. images_to_export = self.processed_images if hasattr(self, "processed_images") and self.processed_images else self.resized_images_cache
  724. for key, img in images_to_export.items():
  725. # filename = f"aligned_{key}.{extension}"
  726. filename = f"image{key}.{extension}"
  727. save_path = os.path.join(target_dir, filename)
  728. try:
  729. if fmt == "dcm":
  730. arr = np.array(img.convert("L")) # DICOMは通常モノクロ
  731. ds = FileDataset(save_path, {}, file_meta=Dataset(), preamble=b"\0" * 128)
  732. ds.Modality = 'OT' # Other
  733. ds.ContentDate = datetime.now().strftime('%Y%m%d')
  734. ds.ContentTime = time.strftime('%H%M%S')
  735. ds.Rows, ds.Columns = arr.shape
  736. ds.SamplesPerPixel = 1
  737. ds.PhotometricInterpretation = "MONOCHROME2"
  738. ds.BitsStored = 8
  739. ds.BitsAllocated = 8
  740. ds.HighBit = 7
  741. ds.PixelRepresentation = 0
  742. ds.PixelData = arr.tobytes()
  743. ds.save_as(save_path)
  744. else:
  745. img.save(save_path, format=save_format)
  746. except Exception as e:
  747. print(f"[ERROR] Failed to save {save_path}: {e}")
  748. self.label_status.setText(f"✅ Exported images as {fmt.upper()} to: {target_dir}")
  749. def smart_undo(self):
  750. key = self.get_current_image_key()
  751. # ① 手描きパスのUndo(最優先)
  752. if key in self.drawn_paths_per_image and self.drawn_paths_per_image[key]:
  753. self.undo_last_path()
  754. print("[INFO] Ctrl+Z → undo_last_drawn_path done")
  755. return
  756. # ② 通常の1画像Undo(先にチェック)
  757. if key in self.undo_stack and self.undo_stack[key]:
  758. self.undo_edit(key)
  759. print("[INFO] Ctrl+Z → undo_svg_edit done")
  760. return
  761. # ③ 全画像対象のUndo(key="__global__" を渡す!)
  762. if "__global__" in self.undo_stack and self.undo_stack["__global__"]:
  763. self.undo_edit("__global__")
  764. print("[INFO] Ctrl+Z → undo_global_svg_edit done")
  765. return
  766. # ④ それでも何もなければ
  767. self.label_status.setText("Nothing to undo.")
  768. print("[INFO] Ctrl+Z → nothing to undo")
  769. def get_current_image_key(self):
  770. """現在の画像インデックスからキー(ファイル名)を取得"""
  771. keys = list(self.image_paths.keys())
  772. if 0 <= self.current_index < len(keys):
  773. return keys[self.current_index]
  774. return None
  775. #黒背景を消すための
  776. def _normalize_color(self, fill, style):
  777. def rgb_to_hex(rgb_str):
  778. match = re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', rgb_str)
  779. if match:
  780. r, g, b = map(int, match.groups())
  781. return f'#{r:02x}{g:02x}{b:02x}'
  782. return rgb_str.strip().lower()
  783. color = ""
  784. if style and "fill:" in style:
  785. match = re.search(r'fill:([^;"]+)', style)
  786. if match:
  787. color = match.group(1).strip().lower()
  788. elif fill:
  789. color = fill.strip().lower()
  790. if color.startswith("rgb"):
  791. return rgb_to_hex(color)
  792. return color
  793. def _find_parent(self, root, target):
  794. for parent in root.iter():
  795. if target in list(parent):
  796. return parent
  797. return None
  798. def load_files_from_folder(self, folder, extensions):
  799. files = {}
  800. for file in sorted(os.listdir(folder)):
  801. if any(file.lower().endswith(ext) for ext in extensions):
  802. key = os.path.splitext(file)[0][-4:] # 末尾4桁(例:0001)
  803. files[key] = os.path.join(folder, file)
  804. return files
  805. def display_current_image(self):
  806. # 🔄 オーバーレイは画像切り替え時に削除
  807. if self.overlay_image_item:
  808. self.scene.removeItem(self.overlay_image_item)
  809. self.overlay_image_item = None
  810. if not self.image_paths:
  811. return
  812. key = f"{self.current_index + 1:04}"
  813. filename = os.path.basename(self.image_paths.get(key, "N/A"))
  814. self.label_status.setText(f"Displaying {filename} ({self.current_index + 1}/{len(self.image_paths)})")
  815. # 🧠 現在の表示状態を保持
  816. current_transform = self.graphicsView.transform()
  817. h_value = self.graphicsView.horizontalScrollBar().value()
  818. v_value = self.graphicsView.verticalScrollBar().value()
  819. self.scene.clear()
  820. # 統一されたキャンバスサイズの画像を使用
  821. # img_pil = self.resized_images_cache[key]
  822. if hasattr(self, "processed_images") and key in self.processed_images:
  823. img_pil = self.processed_images[key]
  824. else:
  825. img_pil = self.resized_images_cache[key]
  826. img_np = np.array(img_pil.convert("RGB"))
  827. h, w, ch = img_np.shape
  828. bytes_per_line = ch * w
  829. qimage = QImage(img_np.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
  830. pixmap = QPixmap.fromImage(qimage)
  831. pixmap_item = QGraphicsPixmapItem(pixmap)
  832. pixmap_item.setPos(0, 0)
  833. self.scene.addItem(pixmap_item)
  834. # SVGマスクを表示
  835. if key in self.mask_paths:
  836. from xml.etree import ElementTree as ET
  837. from io import BytesIO
  838. from PyQt6.QtGui import QPainter
  839. tree = ET.parse(self.mask_paths[key])
  840. root = tree.getroot()
  841. hex_colors = [f'#{r:02x}{g:02x}{b:02x}' for (r, g, b) in self.color_labels]
  842. for elem in list(root.iter()):
  843. fill = elem.attrib.get("fill", "")
  844. style = elem.attrib.get("style", "")
  845. if not fill and "fill:" in style:
  846. match = re.search(r'fill:([^;\"]+)', style)
  847. if match:
  848. fill = match.group(1).strip()
  849. fill_lower = fill.lower() if fill else ""
  850. for i, hex_str in enumerate(hex_colors):
  851. if fill_lower == hex_str:
  852. visible = self.checkboxes[i].isChecked()
  853. if not visible:
  854. elem.attrib["display"] = "none"
  855. break
  856. svg_bytes = BytesIO()
  857. tree.write(svg_bytes, encoding='utf-8')
  858. svg_bytes.seek(0)
  859. renderer = QSvgRenderer(svg_bytes.read())
  860. image = QImage(w, h, QImage.Format.Format_ARGB32)
  861. image.fill(0)
  862. painter = QPainter(image)
  863. renderer.render(painter)
  864. painter.end()
  865. svg_pixmap = QPixmap.fromImage(image)
  866. svg_item = QGraphicsPixmapItem(svg_pixmap)
  867. svg_item.setOpacity(0.3)
  868. svg_item.setZValue(1)
  869. self.scene.addItem(svg_item)
  870. self.graphicsView.setSceneRect(0, 0, self.canvas_width, self.canvas_height)
  871. self.graphicsView.setAlignment(Qt.AlignmentFlag.AlignCenter)
  872. self.graphicsView.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
  873. self.graphicsView.setTransform(current_transform)
  874. self.graphicsView.horizontalScrollBar().setValue(h_value)
  875. self.graphicsView.verticalScrollBar().setValue(v_value)
  876. # クロップ用ボックス再表示
  877. if self.crop_box_coords:
  878. x1, y1, x2, y2 = self.crop_box_coords
  879. rect = QRectF(QPointF(x1, y1), QPointF(x2, y2))
  880. if hasattr(self, 'current_crop_rect_item') and self.current_crop_rect_item:
  881. self.scene.removeItem(self.current_crop_rect_item)
  882. self.current_crop_rect_item = ResizableRectItem(rect)
  883. self.scene.addItem(self.current_crop_rect_item)
  884. # # このフレームにボックスがあれば再表示
  885. # if self.current_index in self.box_per_frame:
  886. # p1, p2 = self.box_per_frame[self.current_index]
  887. # rect = QRectF(p1, p2).normalized()
  888. # box_item = QGraphicsRectItem(rect)
  889. # box_item.setPen(QPen(Qt.GlobalColor.red, 2))
  890. # box_item.setZValue(10)
  891. # self.scene.addItem(box_item)
  892. # グレースケール画像(OpenCV)をスナップ用にセット
  893. gray_path = self.image_paths[key]
  894. gray = cv2.imread(gray_path, cv2.IMREAD_GRAYSCALE)
  895. self.graphicsView.gray_image = gray
  896. # ✅ 画像に対応するクロップボックスを再描画
  897. key = self.get_current_image_key()
  898. if key in self.crop_box_per_frame:
  899. rect = self.crop_box_per_frame[key]
  900. self.current_crop_rect_item = ResizableRectItem(rect)
  901. self.scene.addItem(self.current_crop_rect_item)
  902. # ✅ 全画像で共通のクロップ枠があれば描画
  903. if self.crop_box_global_rect:
  904. # self.current_crop_rect_item = ResizableRectItem(self.crop_box_global_rect)
  905. self.current_crop_rect_item = ResizableRectItem(self.crop_box_global_rect, main_window=self)
  906. self.current_crop_rect_item.setPen(QPen(Qt.GlobalColor.red, 2))
  907. self.current_crop_rect_item.setZValue(10)
  908. self.scene.addItem(self.current_crop_rect_item)
  909. def resizeEvent(self, event):
  910. super().resizeEvent(event)
  911. if hasattr(self, "scene") and self.scene and not self.scene.itemsBoundingRect().isNull():
  912. self.graphicsView.resetTransform()
  913. self.graphicsView.fitInView(self.scene.itemsBoundingRect(), Qt.AspectRatioMode.KeepAspectRatio)
  914. def eventFilter(self, source, event):
  915. if event.type() == event.Type.KeyPress:
  916. key = event.key()
  917. if key in (Qt.Key.Key_PageDown, Qt.Key.Key_F, Qt.Key.Key_J):
  918. if self.current_index + 1 < len(self.image_paths):
  919. self.current_index += 1
  920. self.image_pristine = False # 🔸 操作フラグをオフ
  921. self.display_current_image()
  922. return True
  923. elif key in (Qt.Key.Key_PageUp, Qt.Key.Key_R, Qt.Key.Key_U):
  924. if self.current_index > 0:
  925. self.current_index -= 1
  926. self.image_pristine = False # 🔸 操作フラグをオフ
  927. self.display_current_image()
  928. return True
  929. # 🔁 左回転(Q または I)
  930. elif key in (Qt.Key.Key_Q, Qt.Key.Key_I):
  931. self.rotate_preview(-0.1)
  932. return True
  933. # 🔁 右回転(E または P)
  934. elif key in (Qt.Key.Key_E, Qt.Key.Key_P):
  935. self.rotate_preview(+0.1)
  936. return True
  937. # ⬆ 上に移動(W, O, ↑)
  938. elif key in (Qt.Key.Key_W, Qt.Key.Key_O, Qt.Key.Key_Up):
  939. self.translate_preview(0, -1)
  940. return True
  941. # ⬇ 下に移動(S, L, ↓)
  942. elif key in (Qt.Key.Key_S, Qt.Key.Key_L, Qt.Key.Key_Down):
  943. self.translate_preview(0, 1)
  944. return True
  945. # ⬅ 左に移動(A, K, ←)
  946. elif key in (Qt.Key.Key_A, Qt.Key.Key_K, Qt.Key.Key_Left):
  947. self.translate_preview(-1, 0)
  948. return True
  949. # ➡ 右に移動(D, ;, →)
  950. elif key in (Qt.Key.Key_D, Qt.Key.Key_Semicolon, Qt.Key.Key_Right):
  951. self.translate_preview(1, 0)
  952. return True
  953. elif event.type() == event.Type.Wheel and source == self.graphicsView.viewport():
  954. event.accept()
  955. modifiers = QApplication.keyboardModifiers()
  956. delta = event.angleDelta().y()
  957. if modifiers == Qt.KeyboardModifier.ControlModifier:
  958. factor = 1.25 if delta > 0 else 0.8
  959. self.graphicsView.scale(factor, factor)
  960. return True
  961. elif modifiers == Qt.KeyboardModifier.ShiftModifier:
  962. hbar = self.graphicsView.horizontalScrollBar()
  963. hbar.setValue(hbar.value() - delta)
  964. return True
  965. elif modifiers == Qt.KeyboardModifier.NoModifier:
  966. vbar = self.graphicsView.verticalScrollBar()
  967. vbar.setValue(vbar.value() - delta)
  968. return True
  969. elif event.type() == event.Type.MouseMove and source == self.graphicsView.viewport():
  970. # 🔍 共通デバッグ出力
  971. # print(f"[DEBUG] MouseMove: box_mode={self.box_mode}, box_points={len(self.box_points)}, calibration_mode={self.calibration_mode}, calibration_points={len(self.calibration_points)}")
  972. # ✅ ボックスモードで2点目をまだ選んでいないとき
  973. if self.box_mode and len(self.box_points) == 1:
  974. p1 = self.box_points[0]
  975. p2 = self.graphicsView.mapToScene(event.pos())
  976. if self.temp_box_item:
  977. self.scene.removeItem(self.temp_box_item)
  978. self.temp_box_item = None
  979. rect = QRectF(p1, p2).normalized()
  980. self.temp_box_item = QGraphicsRectItem(rect)
  981. self.temp_box_item.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.DashLine))
  982. self.scene.addItem(self.temp_box_item)
  983. return True
  984. # ✅ ボックスモードでまだ最初の点を選んでいないとき(クロスヘア)
  985. if self.box_mode and len(self.box_points) < 1:
  986. # クロスヘア描画(MouseMove)
  987. scene_pos = self.graphicsView.mapToScene(event.pos())
  988. self.current_crosshair_pos = scene_pos # ← 🔴 追加!
  989. x, y = scene_pos.x(), scene_pos.y()
  990. # 以前のクロスヘアを削除
  991. if hasattr(self, "temp_crosshair_hline") and self.temp_crosshair_hline:
  992. self.scene.removeItem(self.temp_crosshair_hline)
  993. if hasattr(self, "temp_crosshair_vline") and self.temp_crosshair_vline:
  994. self.scene.removeItem(self.temp_crosshair_vline)
  995. scene_rect = self.graphicsView.sceneRect()
  996. # 🔽 右方向にだけ伸びる水平線(左端がマウス位置)
  997. hline_path = QPainterPath()
  998. hline_path.moveTo(x, y)
  999. hline_path.lineTo(scene_rect.right(), y)
  1000. self.temp_crosshair_hline = QGraphicsPathItem(hline_path)
  1001. self.temp_crosshair_hline.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.SolidLine))
  1002. self.temp_crosshair_hline.setZValue(999)
  1003. self.scene.addItem(self.temp_crosshair_hline)
  1004. # 🔽 下方向にだけ伸びる垂直線(上端がマウス位置)
  1005. vline_path = QPainterPath()
  1006. vline_path.moveTo(x, y)
  1007. vline_path.lineTo(x, scene_rect.bottom())
  1008. self.temp_crosshair_vline = QGraphicsPathItem(vline_path)
  1009. self.temp_crosshair_vline.setPen(QPen(Qt.GlobalColor.red, 2, Qt.PenStyle.SolidLine))
  1010. self.temp_crosshair_vline.setZValue(999)
  1011. self.scene.addItem(self.temp_crosshair_vline)
  1012. return True
  1013. # ✅ キャリブレーション線の仮表示
  1014. if self.calibration_mode and len(self.calibration_points) == 1:
  1015. p1 = self.calibration_points[0]
  1016. p2 = self.graphicsView.mapToScene(event.pos())
  1017. if hasattr(self, "temp_line_item") and self.temp_line_item:
  1018. self.scene.removeItem(self.temp_line_item)
  1019. path = QPainterPath()
  1020. path.moveTo(p1)
  1021. path.lineTo(p2)
  1022. self.temp_line_item = QGraphicsPathItem(path)
  1023. self.temp_line_item.setPen(QPen(Qt.GlobalColor.magenta, 1, Qt.PenStyle.DashLine))
  1024. self.scene.addItem(self.temp_line_item)
  1025. return True
  1026. elif event.type() == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
  1027. # ✅ マウスカーソルの現在位置を scene 座標に変換(ズレ防止)
  1028. scene_pos = self.graphicsView.mapToScene(
  1029. self.graphicsView.viewport().mapFromGlobal(QCursor.pos())
  1030. )
  1031. # 🔹 キャリブレーションモード
  1032. if self.calibration_mode:
  1033. self.calibration_points.append(scene_pos)
  1034. if len(self.calibration_points) == 2:
  1035. p1, p2 = self.calibration_points
  1036. # 確定線
  1037. path = QPainterPath()
  1038. path.moveTo(p1)
  1039. path.lineTo(p2)
  1040. line_item = QGraphicsPathItem(path)
  1041. line_item.setPen(QPen(Qt.GlobalColor.magenta, 2))
  1042. self.scene.addItem(line_item)
  1043. # 仮線削除
  1044. if hasattr(self, "temp_line_item") and self.temp_line_item:
  1045. self.scene.removeItem(self.temp_line_item)
  1046. self.temp_line_item = None
  1047. px_length = ((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2) ** 0.5
  1048. real_length_mm = self.spin_mm_input.value()
  1049. self.mm_per_px = real_length_mm / px_length if px_length != 0 else 1.0
  1050. self.z_spacing_mm = self.spin_z_interval.value()
  1051. self.label_status.setText(
  1052. f"Calibration complete: {px_length:.2f}px = {real_length_mm:.2f}mm → 1px = {self.mm_per_px:.4f} mm, Z spacing = {self.z_spacing_mm:.4f} mm"
  1053. )
  1054. self.save_calibration_to_csv()
  1055. self.calibration_mode = False
  1056. self.calibration_points = []
  1057. return True
  1058. # 🔹 ボックスプロンプトモード
  1059. if self.box_mode:
  1060. # self.box_points.append(scene_pos)
  1061. if self.box_mode:
  1062. if len(self.box_points) == 0:
  1063. # ✅ 1点目はクロスヘア(狙った位置)
  1064. if hasattr(self, "current_crosshair_pos"):
  1065. self.box_points.append(self.current_crosshair_pos)
  1066. else:
  1067. self.box_points.append(self.graphicsView.mapToScene(event.pos()))
  1068. elif len(self.box_points) == 1:
  1069. # ✅ 2点目は必ずクリック位置(誤差を防ぐため)
  1070. # scene_pos = self.graphicsView.mapToScene(event.pos())
  1071. scene_pos = self.graphicsView.mapToScene(self.graphicsView.viewport().mapFromGlobal(QCursor.pos()))
  1072. # 🔽 念のため:クリック時にも crosshair_pos を更新(マウスが動いてない場合)
  1073. self.current_crosshair_pos = scene_pos
  1074. self.box_points.append(scene_pos)
  1075. # 1点目クリック後 → クロスヘア削除
  1076. if len(self.box_points) == 1:
  1077. if hasattr(self, "temp_crosshair_hline") and self.temp_crosshair_hline:
  1078. self.scene.removeItem(self.temp_crosshair_hline)
  1079. self.temp_crosshair_hline = None
  1080. if hasattr(self, "temp_crosshair_vline") and self.temp_crosshair_vline:
  1081. self.scene.removeItem(self.temp_crosshair_vline)
  1082. self.temp_crosshair_vline = None
  1083. # 2点目クリック → ボックス確定
  1084. elif len(self.box_points) == 2:
  1085. p1, p2 = self.box_points
  1086. rect = QRectF(p1, p2).normalized()
  1087. if hasattr(self, 'confirmed_box_item') and self.confirmed_box_item:
  1088. self.scene.removeItem(self.confirmed_box_item)
  1089. # ✅ 先に確定ボックスを追加
  1090. # ✅ ResizableRectItem を current_crop_rect_item に代入(apply_crop で使うため)
  1091. if hasattr(self, 'current_crop_rect_item') and self.current_crop_rect_item:
  1092. self.scene.removeItem(self.current_crop_rect_item)
  1093. self.current_crop_rect_item = ResizableRectItem(rect)
  1094. self.current_crop_rect_item.setPen(QPen(Qt.GlobalColor.red, 2))
  1095. self.current_crop_rect_item.setZValue(10) # 必要なら重ね順設定
  1096. self.scene.addItem(self.current_crop_rect_item)
  1097. # ✅ ResizableRectItem を current_crop_rect_item に代入(apply_crop で使うため)
  1098. if hasattr(self, 'current_crop_rect_item') and self.current_crop_rect_item:
  1099. self.scene.removeItem(self.current_crop_rect_item)
  1100. self.current_crop_rect_item = ResizableRectItem(rect)
  1101. self.current_crop_rect_item.setPen(QPen(Qt.GlobalColor.red, 2))
  1102. self.current_crop_rect_item.setZValue(10)
  1103. self.scene.addItem(self.current_crop_rect_item)
  1104. self.crop_box_global_rect = rect # ✅ 全画像共通の赤枠として保存
  1105. # # ✅ ここで保存!画像ごとの赤枠を記録
  1106. # key = self.get_current_image_key()
  1107. # self.crop_box_per_frame[key] = rect
  1108. # ✅ 仮ボックスがあれば削除
  1109. if self.temp_box_item:
  1110. self.scene.removeItem(self.temp_box_item)
  1111. self.temp_box_item = None
  1112. # ✅ 終了処理
  1113. self.box_mode = False
  1114. width = self.graphicsView.sceneRect().width()
  1115. height = self.graphicsView.sceneRect().height()
  1116. top_left = (p1.x() / width * 100, p1.y() / height * 100)
  1117. bottom_right = (p2.x() / width * 100, p2.y() / height * 100)
  1118. self.last_box_prompt = (top_left, bottom_right)
  1119. self.last_used_box_px = ((p1.x(), p1.y()), (p2.x(), p2.y()))
  1120. self.label_status.setText(f"Box set: {top_left} → {bottom_right}")
  1121. self.last_used_box_index = self.current_index # ✅ ボックスを置いたフレーム番号を記録
  1122. # ✅ フレームに応じて保存
  1123. self.box_per_frame[self.current_index] = ((p1, p2))
  1124. self.box_points = []
  1125. if len(self.box_points) >= 2:
  1126. print(f"[DEBUG] point1: ({self.box_points[0].x():.2f}, {self.box_points[0].y():.2f})")
  1127. print(f"[DEBUG] point2: ({self.box_points[1].x():.2f}, {self.box_points[1].y():.2f})")
  1128. return True
  1129. return super().eventFilter(source, event)
  1130. def save_calibration_to_csv(self):
  1131. import csv
  1132. from pathlib import Path
  1133. if self.mm_per_px is None or self.z_spacing_mm is None:
  1134. print("[WARN] Calibration values not set")
  1135. return
  1136. try:
  1137. first_img_path = self.image_paths.get("0001") or list(self.image_paths.values())[0]
  1138. img = Image.open(first_img_path)
  1139. width, height = img.width, img.height
  1140. except Exception as e:
  1141. print(f"[WARN] Failed to get image size: {e}")
  1142. width, height = 0, 0
  1143. depth = len(self.image_paths)
  1144. volume_table = [
  1145. ["Width", "Height", "Depth"],
  1146. [str(width), str(height), str(depth)],
  1147. ["X Spacing", "Y Spacing", "Z Spacing"],
  1148. [str(self.mm_per_px), str(self.mm_per_px), str(self.z_spacing_mm)],
  1149. ["X Origin", "Y Origin", "Z Origin"],
  1150. ["0", "0", "0"]
  1151. ]
  1152. input_folder_name = Path(self.image_paths.get("0001") or list(self.image_paths.values())[0]).parent.name
  1153. csv_filename = f"{input_folder_name}_volinf.csv"
  1154. # csv_path = Path(self.output_mask_dir).parent / csv_filename
  1155. csv_path = Path(self.default_output_dir) / csv_filename
  1156. with open(csv_path, "w", newline="", encoding="utf-8") as f:
  1157. writer = csv.writer(f)
  1158. writer.writerows(volume_table)
  1159. print(f"[INFO] Calibration info saved to: {csv_path}")
  1160. def save_svg_state_for_undo(self, key=None):
  1161. """
  1162. SVGの状態をUndo用に保存。
  1163. - key を指定:そのキーのみ保存
  1164. - key を None:全mask_paths分のsnapshotを保存
  1165. """
  1166. if key is None:
  1167. # 一括保存(全画像)
  1168. snapshot = {}
  1169. for k, path in self.mask_paths.items():
  1170. if os.path.exists(path):
  1171. with open(path, "r", encoding="utf-8") as f:
  1172. snapshot[k] = f.read()
  1173. if snapshot:
  1174. self.undo_stack.setdefault("__global__", []).append(snapshot)
  1175. self.redo_stack["__global__"] = []
  1176. else:
  1177. # 単一キーの保存(従来の動作)
  1178. if key not in self.mask_paths:
  1179. return
  1180. path = self.mask_paths[key]
  1181. with open(path, "r", encoding="utf-8") as f:
  1182. svg_text = f.read()
  1183. self.undo_stack.setdefault(key, []).append(svg_text)
  1184. self.redo_stack[key] = []
  1185. if __name__ == "__main__":
  1186. app = QApplication(sys.argv)
  1187. window = Main()
  1188. app.installEventFilter(window) # ← ここでアプリケーション全体にフィルターを適用
  1189. window.show()
  1190. sys.exit(app.exec())

AlignRef.py at commit 722a95f, under Apache-2.0 · at the source

Overview

Authors: Satoru Muro1, Takuya Ibara2, Akimoto Nimura2, Keiichi Akita1
  1. Department of Clinical Anatomy, Graduate School of Medical and Dental Sciences, Institute of Science Tokyo, Tokyo, Japan
  2. Department of Functional Joint Anatomy, Biomedical Engineering Laboratory, Institute of Industry Incubation, Institute of Science Tokyo, Tokyo, Japan
Institutions: Institute of Science Tokyo (Japan)
Journal: Journal of anatomy, article 10.1111/joa.70203
Dates: received 10 December 2025; accepted 18 June 2026; published online 3 July 2026; in print July 2026
Type: Methods article · Language: English
License: CC BY
Identifiers: DOI 10.1111/joa.70203 · PMID 42397088 · PMCID PMC13398511 · OpenAlex W7167236494
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: histology / microscopy (modality), methods / tools (subfield)
Keywords: 3D reconstruction, image registration, serial histological sections
Topic: Anatomy and Medical Technology (Biomedical Engineering, Engineering), according to OpenAlex
Citations: not cited yet (Europe PMC); 6 references in the paper

Abstract

The accurate alignment of serial histological sections is essential for preserving anatomical continuity in 3D reconstruction. Although automated registration tools can efficiently correct global alignment errors, they often fail to resolve local misalignments caused by sectioning artifacts, tissue deformation, staining variability, or missing slices. Thus, we propose a practical two‐step registration workflow that uses MultiStackReg (an ImageJ/Fiji plugin) for automatic alignment and AlignRef (a standalone application for interactive adjustment) for manual refinement. In the first step, MultiStackReg performs global registration by using rigid body transformations. In the second step, AlignRef corrects residual misalignments through semi‐transparent overlay visualization, keyboard‐based translation and rotation, and batch propagation of recorded transformations across selected slice ranges. We applied our workflow to 135 serial sections of a Carnegie Stage 15 embryo from the Virtual Human Embryo dataset that were stained with hematoxylin and eosin. MultiStackReg resolved most global inconsistencies, whereas AlignRef enabled the precise adjustment of subtle local deviations, particularly in curved structures such as neural tubes and limb buds. After automatic registration with MultiStackReg, the subsequent manual refinement step using AlignRef was completed in approximately 30 min and produced a suitable stack for 3D reconstruction. This two‐step workflow balances automation with expert‐guided correction and provides an accessible, reproducible, and anatomically precise method for the serial section alignment of morphological and developmental anatomy.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 1 match between paragraphs and lines of code.

SatoruMuro/AlignRef

License: Apache-2.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: 722a95feb5029de7b959b3c387023acceb73f673, 11 September 2026
Languages: JavaScript (20), Python (2)
Size: 184 files, 22 scripts
Software Heritage: not archived
Found in: the text, “Manual alignment using AlignRef”
Holds: README, license file, tests, continuous integration, documentation
Not found: CITATION.cff, environment file
Tools: NumPy (1 file), OpenCV (1 file), Pillow (1 file), pydicom (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
24 files

SatoruMuro/SAM2GUIfor3Drecon

License: Apache-2.0
State: the link answers, verified on 27 September 2026
Evidence: files inventoried
Commit: ce8560d915ae9d0c5cacc3557f1badfb83850849, 24 September 2026
Languages: Python (77), Jupyter (19), JavaScript (13)
Size: 684 files, 109 scripts
Software Heritage: not archived
Found in: the text, “RESULTS”
Holds: README, license file, environment (SegRef3D/requirements.txt, SegRef3D/requirements/requirements-cpu.txt, SegRef3D/requirements/requirements-gpu-cu128.txt, SegRef3D/requirements/requirements-lite.txt), tests, continuous integration, documentation, 19 notebooks
Not found: CITATION.cff
Tools: NumPy (57 files), Pillow (36 files), PyTorch (24 files), NiBabel (21 files), OpenCV (16 files), Matplotlib (10 files), pydicom (10 files), Hugging Face Transformers (9 files), MONAI (4 files), scikit-image (4 files), SciPy (3 files), SimpleITK (2 files), pandas (1 file)
Availability: 1 check, the latest on 27 September 2026: the link answers
  • 27 September 2026: the link answers
111 files

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 131 scripts, each with its path and the digest of its content;
  • 1 match between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability statement

The data that support the findings of this study are openly available in AlignRef at https://github.com/SatoruMuro/AlignRef.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, pages, dates, 4 authors, 3 keywords, 6 references.

Cite

This paper

Muro, S., Ibara, T., Nimura, A., & Akita, K. (2026). Two-step workflow integrating automatic registration and manual refinement for the accurate alignment of serial histological sections in 3D reconstruction. Journal of anatomy, 10.1111/joa.70203. https://doi.org/10.1111/joa.70203

BibTeX

@article{muro2026two,
author = {Muro, Satoru and Ibara, Takuya and Nimura, Akimoto and Akita, Keiichi},
title = {{Two-step workflow integrating automatic registration and manual refinement for the accurate alignment of serial histological sections in 3D reconstruction}},
journal = {Journal of anatomy},
year = {2026},
month = jul,
pages = {10.1111/joa.70203},
publisher = {Wiley},
issn = {0021-8782},
doi = {10.1111/joa.70203},
url = {https://doi.org/10.1111/joa.70203},
pmid = {42397088},
pmcid = {PMC13398511}
}

RIS

TY - JOUR
AU - Muro, Satoru
AU - Ibara, Takuya
AU - Nimura, Akimoto
AU - Akita, Keiichi
TI - Two-step workflow integrating automatic registration and manual refinement for the accurate alignment of serial histological sections in 3D reconstruction
T2 - Journal of anatomy
J2 - J Anat
PY - 2026
DA - 2026/07/03
SP - 10.1111/joa.70203
SN - 0021-8782
PB - Wiley
DO - 10.1111/joa.70203
UR - https://doi.org/10.1111/joa.70203
LA - en
ER -

CSL-JSON

{
"id": "10.1111/joa.70203",
"type": "article-journal",
"title": "Two-step workflow integrating automatic registration and manual refinement for the accurate alignment of serial histological sections in 3D reconstruction",
"container-title": "Journal of anatomy",
"author": [
{
"family": "Muro",
"given": "Satoru"
},
{
"family": "Ibara",
"given": "Takuya"
},
{
"family": "Nimura",
"given": "Akimoto"
},
{
"family": "Akita",
"given": "Keiichi"
}
],
"container-title-short": "J Anat",
"page": "10.1111/joa.70203",
"DOI": "10.1111/joa.70203",
"PMID": "42397088",
"PMCID": "PMC13398511",
"ISSN": "0021-8782",
"publisher": "Wiley",
"URL": "https://doi.org/10.1111/joa.70203",
"language": "en",
"issued": {
"date-parts": [
[
2026,
7,
3
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.3389/frai.2026.1771088 [code]
Few-shot deployment of pretrained MRI transformers in brain imaging tasks.
Journal: Frontiers in artificial intelligence
In common: MONAI, pydicom, SimpleITK, 9 other tools, methods / tools
[2] doi:10.1371/journal.pcbi.1014555 [code]
Body surface potential driven personalisation of electrophysiological digital twins in hypertrophic cardiomyopathy.
Journal: PLoS computational biology
In common: MONAI, pydicom, SimpleITK, 8 other tools
[3] doi:10.1016/j.patter.2026.101538 [code]
A multi-modal foundation model for brain disease diagnosis and medical imaging.
Journal: Patterns (New York, N.Y.)
In common: MONAI, Hugging Face Transformers, OpenCV, 8 other tools
[4] doi:10.1016/j.crmeth.2026.101473 [code]
AmygdalaGo-BOLT for boundary-aware segmentation of the human amygdala.
Journal: Cell reports methods
In common: MONAI, SimpleITK, OpenCV, 7 other tools, methods / tools
[5] doi:10.1162/imag.a.1326 [code]
RAVEN: Robust, generalizable, multi-resolution structural MRI upsampling using autoencoders.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: Hugging Face Transformers, SimpleITK, OpenCV, 8 other tools
[6] doi:10.1186/s13244-026-02296-3 [code]
A pre-trained foundation model framework for multiplanar MRI classification of extramural vascular invasion and mesorectal fascia invasion in rectal cancer.
Journal: Insights into imaging
In common: MONAI, pydicom, SimpleITK, 7 other tools
[7] doi:10.1038/s41467-026-76011-7 [code]
Human cortex organizes dynamic co-fluctuations along the sensorimotor-association axis.
Journal: Nature communications
In common: MONAI, SimpleITK, OpenCV, 7 other tools
[8] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: pydicom, Hugging Face Transformers, OpenCV, 7 other tools
[9] doi:10.3389/fnins.2026.1870124 [code]
An end-to-end pipeline for automated fetal brain segmentation and biometry from 3D SSFP MRI.
Journal: Frontiers in neuroscience
In common: MONAI, SimpleITK, OpenCV, 7 other tools
[10] doi:10.1002/alz.71649 [code]
Postmortem brain MRI reveals differential associations of subcortical and limbic volumes with cortical thinning and neurodegenerative pathologies.
Journal: Alzheimer's & dementia : the journal of the Alzheimer's Association
In common: SimpleITK, OpenCV, scikit-image, 7 other tools, histology / microscopy

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.