fig-laba2-plan.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. # -*- coding: utf-8 -*-
  2. """
  3. Лаба 2, рисунок «План трассы». Горизонтали векторизуются из скана варианта 6
  4. (.work/laba2-contours-paths.pkl, координаты в пикселях кадра карты),
  5. затем на карту наносится трасса А–Б: тангенсы с круговыми кривыми,
  6. пикетаж, тоннель у водораздела.
  7. Схема трассы задаётся списком вершин углов поворота ВУ (пиксели кадра) и
  8. радиусами кривых; всё остальное (пикетаж, пересечения горизонталей, элементы
  9. кривых) вычисляется. Данные профиля сохраняются в .work/laba2-profile-data.pkl.
  10. """
  11. import numpy as np, pickle, json, math
  12. with open('.work/laba2-contours-paths.pkl', 'rb') as f:
  13. raw = pickle.load(f)
  14. # --- 1. Склейка разорванных горизонталей и упорядочивание -------------------
  15. # (пути 11+10 и 12+13 разорваны маркером пункта А)
  16. contours = [
  17. np.vstack([raw[11], raw[10]]), # 250 (через пункт А)
  18. np.vstack([raw[12], raw[13]]), # 240 (нижняя)
  19. ] + [raw[i] for i in range(10)] # остальные, с юга на север: p9..p0
  20. # юг -> север по положению на западном крае (x < 170)
  21. def west_y(p):
  22. m = (p[:, 0] >= 20) & (p[:, 0] <= 170)
  23. return np.mean(p[m, 1]) if m.any() else p[:, 1].mean()
  24. contours.sort(key=west_y, reverse=True) # южные первыми (y вниз)
  25. # южный склон 240->290, водораздел 300 (седьмая линия), северный склон 290->250
  26. ELEV = [240, 250, 260, 270, 280, 290, 300, 290, 280, 270, 260, 250]
  27. # проверка унимодальности отметок на вертикальных разрезах
  28. for xq in (60, 200, 400, 600, 760):
  29. seq = []
  30. for p, z in zip(contours, ELEV):
  31. m = np.abs(p[:, 0] - xq) < 30
  32. if m.any():
  33. seq.append((np.mean(p[m, 1]), z))
  34. seq.sort()
  35. zs = [z for _, z in seq]
  36. peak = zs.index(max(zs))
  37. ok = zs[:peak+1] == sorted(zs[:peak+1]) and zs[peak:] == sorted(zs[peak:], reverse=True)
  38. print(f'x={xq}: {zs} {"OK" if ok else "!! НЕ унимодально"}')
  39. # --- 2. Масштаб --------------------------------------------------------------
  40. PX_PER_KM = 262.7 # кадр карты 788 px = 3.0 км (масштаб 1:10 000)
  41. M_PER_PX = 1000.0 / PX_PER_KM
  42. # --- 3. Сглаживание горизонталей --------------------------------------------
  43. def smooth(p, k=7):
  44. if len(p) < 2 * k + 1:
  45. return p
  46. ker = np.hanning(k)
  47. ker /= ker.sum()
  48. sm = np.empty_like(p)
  49. for c in (0, 1):
  50. sm[:, c] = np.convolve(p[:, c], ker, mode='same')
  51. sm[:k//2] = p[:k//2]; sm[-(k//2):] = p[-(k//2):]
  52. return sm
  53. contours = [smooth(p) for p in contours]
  54. # --- 4. Трасса: тангенсы и круговые кривые ----------------------------------
  55. # Вершины углов поворота (пиксели кадра, y вниз); НТ = начало трассы (А),
  56. # КТ = конец трассы (Б). (A, R): None для первого/последнего участка
  57. VUS = [
  58. ((260.0, 402.0), None), # НТ = А
  59. ((150.0, 372.0), 300.0), # ВУ1
  60. ((105.0, 255.0), 600.0), # ВУ2
  61. ((55.0, 165.0), 250.0), # ВУ3
  62. ((18.0, 142.0), None), # КТ = Б
  63. ]
  64. def build_alignment(vus):
  65. pts = [] # плотная полилиния оси (px)
  66. pieces = [] # (тип, геометрия, длина_м)
  67. P = [np.array(v, float) for v, _ in vus]
  68. R = [r for _, r in vus]
  69. elem = [] # элементы кривых для надписей
  70. # 1) тангенсы и дуги
  71. segs = [] # последовательность ('l', A, B) | ('a', C, A, B, R, ccw)
  72. cur = P[0]
  73. for i in range(1, len(P) - 1):
  74. V, Pn = P[i], P[i + 1]
  75. u1 = (V - cur) / np.linalg.norm(V - cur)
  76. u2 = (Pn - V) / np.linalg.norm(Pn - V)
  77. cosA = np.clip(np.dot(u1, u2), -1, 1)
  78. d = math.acos(cosA) # угол поворота
  79. Rp = R[i] / M_PER_PX # радиус в px
  80. T = Rp * math.tan(d / 2)
  81. A = V - u1 * T
  82. B = V + u2 * T
  83. cross = u1[0] * u2[1] - u1[1] * u2[0] # >0: поворот по часовой (y вниз)
  84. n = np.array([-u1[1], u1[0]]) * np.sign(cross)
  85. C = A + n * Rp
  86. segs.append(('l', cur, A))
  87. segs.append(('a', C, A, B, Rp, cross < 0))
  88. elem.append({'i': i, 'V': V, 'A': A, 'B': B, 'C': C, 'R_px': Rp,
  89. 'd': math.degrees(d), 'T_px': T,
  90. 'K_m': Rp * d * M_PER_PX, 'T_m': T * M_PER_PX,
  91. 'R_m': R[i], 'right': cross < 0})
  92. cur = B
  93. segs.append(('l', cur, P[-1]))
  94. # 2) плотная выборка и длины
  95. total_px = 0.0
  96. dense = []
  97. for s in segs:
  98. if s[0] == 'l':
  99. _, a, b = s
  100. L = np.linalg.norm(b - a)
  101. n = max(2, int(L))
  102. t = np.linspace(0, 1, n)
  103. pts.append(a[None, :] + t[:, None] * (b - a)[None, :])
  104. total_px += L
  105. else:
  106. _, C, a, b, Rp, ccw = s
  107. a0 = math.atan2(a[1] - C[1], a[0] - C[0])
  108. a1 = math.atan2(b[1] - C[1], b[0] - C[0])
  109. da = a1 - a0
  110. if ccw:
  111. while da > 0: da -= 2 * math.pi
  112. else:
  113. while da < 0: da += 2 * math.pi
  114. n = max(4, int(abs(da) * Rp))
  115. t = np.linspace(a0, a0 + da, n)
  116. pts.append(C[None, :] + Rp * np.column_stack([np.cos(t), np.sin(t)]))
  117. total_px += abs(da) * Rp
  118. align = np.vstack(pts)
  119. s_m = np.r_[0, np.cumsum(np.hypot(*np.diff(align, axis=0).T))] * M_PER_PX
  120. return align, s_m, segs, elem
  121. align, s_m, segs, curves = build_alignment(VUS)
  122. L_total = s_m[-1]
  123. print(f'длина трассы: {L_total:.0f} м; кривых: {len(curves)}')
  124. for c in curves:
  125. print(f" ВУ{c['i']}: У={c['d']:.1f}° R={c['R_m']} T={c['T_m']:.1f} K={c['K_m']:.1f} "
  126. f"{'вправо' if c['right'] else 'влево'}")
  127. # --- 5. Пересечения трассы с горизонталями ----------------------------------
  128. from scipy.spatial import cKDTree
  129. trees = [cKDTree(p) for p in contours]
  130. def elev_along(points):
  131. """ближайшая горизонталь -> отметка (ступенчатая), для контроля"""
  132. z = np.empty(len(points))
  133. for j, tree in enumerate(trees):
  134. pass
  135. d = np.stack([tree.query(points)[0] for tree in trees])
  136. return np.array(ELEV)[np.argmin(d, axis=0)]
  137. crossings = [] # (s, z)
  138. zone_prev = False
  139. idx = trees_ind = np.zeros(len(align), int)
  140. D = np.stack([tree.query(align)[0] for tree in trees])
  141. nearest = np.argmin(D, axis=0)
  142. near = D.min(axis=0)
  143. inzone = near < 1.1
  144. runs = []
  145. i = 0
  146. while i < len(align):
  147. if inzone[i]:
  148. j = i
  149. while j < len(align) and inzone[j]:
  150. j += 1
  151. runs.append((i, j))
  152. i = j
  153. else:
  154. i += 1
  155. for a, b in runs:
  156. mid = (a + b) // 2
  157. crossings.append((s_m[mid], ELEV[nearest[mid]], a, b))
  158. print('пересечения с горизонталями:')
  159. for s, z, a, b in crossings:
  160. print(f' s={s:7.1f} м z={z} м (px {a}..{b})')
  161. # --- 6. Поверхность земли по пересечениям -----------------------------------
  162. # отметки на концах трассы: А стоит на горизонтали 250, Б — между 280 и 270
  163. S_A_Z, S_B_Z = 250.0, 277.5
  164. def ground(s):
  165. cs = [0.0] + [c[0] for c in crossings] + [L_total]
  166. zs = [S_A_Z] + [c[1] for c in crossings] + [S_B_Z]
  167. return float(np.interp(s, cs, zs))
  168. # --- 7. Проектная (красная) линия: участки уклонов ---------------------------
  169. # (s_начало, уклон ‰): 2 выемки у порталов тоннеля 1060->1265
  170. GRADES = [(0.0, 27.0), (400.0, 45.0), (700.0, 10.0), (1060.0, 10.0), (1265.0, -10.0)]
  171. S_P1, S_P2 = 1060.0, 1265.0 # порталы тоннеля
  172. def red_h(s):
  173. h = 250.0
  174. for (s1, i1), (s2, _i2) in zip(GRADES, GRADES[1:]):
  175. if s1 <= s < s2:
  176. return h + (s - s1) * i1 / 1000.0
  177. h += (s2 - s1) * i1 / 1000.0
  178. return h + (s - GRADES[-1][0]) * GRADES[-1][1] / 1000.0
  179. # --- 8. Сохранение данных ---------------------------------------------------
  180. with open('.work/laba2-plan-data.pkl', 'wb') as f:
  181. pickle.dump({
  182. 'contours': contours, 'ELEV': ELEV, 'align': align, 's_m': s_m,
  183. 'curves': curves, 'crossings': crossings, 'M_PER_PX': M_PER_PX,
  184. 'A': np.array(VUS[0][0]), 'B': np.array(VUS[-1][0]),
  185. 'GRADES': GRADES, 'S_P1': S_P1, 'S_P2': S_P2,
  186. 'S_A_Z': S_A_Z, 'S_B_Z': S_B_Z, 'L_total': L_total,
  187. }, f)
  188. print('данные сохранены: .work/laba2-plan-data.pkl')
  189. # --- 9. Чертёж плана --------------------------------------------------------
  190. import matplotlib
  191. matplotlib.use('Agg')
  192. import matplotlib.pyplot as plt
  193. import matplotlib.patheffects as pe
  194. from matplotlib.patches import Circle
  195. # Чертёжный шрифт ГОСТ 2.304-81 тип А (прямой, из системных шрифтов Windows)
  196. import matplotlib.font_manager as fm
  197. fm.fontManager.addfont('C:/Windows/Fonts/GOST2304A.ttf')
  198. matplotlib.rcParams['font.family'] = 'GOST 2.304 type A'
  199. matplotlib.rcParams['mathtext.default'] = 'regular'
  200. RED = '#cc1f2d'
  201. W, H_PX = 788.0, 468.0 # кадр карты, px
  202. ML, MR, MT, MB = 12.0, 12.0, 62.0, 96.0 # поля листа вокруг рамки карты
  203. fig = plt.figure(figsize=((W + ML + MR) / 50, (H_PX + MT + MB) / 50), dpi=200)
  204. ax = fig.add_axes([0, 0, 1, 1])
  205. ax.set_xlim(-ML, W + MR)
  206. ax.set_ylim(H_PX + MB, -MT) # y вниз, как на плане
  207. ax.axis('off')
  208. ax.set_facecolor('white')
  209. fig.patches.append(plt.Rectangle((0, 0), 1, 1, transform=fig.transFigure,
  210. fill=False, ec='black', lw=1.4))
  211. # рамка карты
  212. ax.add_patch(plt.Rectangle((0, 0), W, H_PX, fill=False, ec='black', lw=1.1))
  213. def m2px(pt):
  214. return np.asarray(pt, float)
  215. # километровая сетка через 0,5 км
  216. step = 500.0 / M_PER_PX
  217. xg = np.arange(step, W, step)
  218. yg = np.arange(step, H_PX, step)
  219. for x in xg:
  220. ax.plot([x, x], [0, H_PX], color='0.55', lw=0.4, alpha=0.55, zorder=0.5)
  221. for y in yg:
  222. ax.plot([0, W], [y, y], color='0.55', lw=0.4, alpha=0.55, zorder=0.5)
  223. # горизонтали с подписями отметок
  224. for p, z in zip(contours, ELEV):
  225. ax.plot(p[:, 0], p[:, 1], color='black', lw=0.75, zorder=1)
  226. for xq in (120, 360, 600, 740):
  227. i = np.argmin(np.abs(p[:, 0] - xq))
  228. if abs(p[i, 0] - xq) > 90 or 4 < p[i, 1] < H_PX - 4:
  229. pass
  230. ax.text(p[i, 0] + 3, p[i, 1] - 4, str(z), fontsize=9.5, color='black',
  231. zorder=3, path_effects=[pe.withStroke(linewidth=2.4, foreground='white')])
  232. # трасса: участки вне тоннеля
  233. def s_to_pts(s1, s2):
  234. i1 = np.searchsorted(s_m, s1)
  235. i2 = min(len(s_m) - 1, np.searchsorted(s_m, s2))
  236. return align[i1:i2 + 1]
  237. tun = s_to_pts(S_P1, S_P2)
  238. s_tun = s_m[np.searchsorted(s_m, S_P1):np.searchsorted(s_m, S_P2) + 1]
  239. open1 = s_to_pts(0, S_P1)
  240. open2 = s_to_pts(S_P2, s_m[-1])
  241. for seg in (open1, open2):
  242. ax.plot(seg[:, 0], seg[:, 1], color=RED, lw=2.0, zorder=4, solid_capstyle='round')
  243. # тоннель: две параллельные линии со штрихами
  244. d = np.gradient(tun, axis=0)
  245. nrm = np.column_stack([d[:, 1], -d[:, 0]])
  246. nrm /= np.hypot(nrm[:, 0], nrm[:, 1])[:, None] + 1e-9
  247. for sgn in (+1, -1):
  248. off = tun + sgn * 3.2 * nrm
  249. ax.plot(off[:, 0], off[:, 1], color=RED, lw=1.3, zorder=4)
  250. for s in (S_P1, S_P2):
  251. k = np.searchsorted(s_tun, s)
  252. base = tun[min(k, len(tun) - 1)]
  253. ax.plot([base[0] - 6.5 * nrm[k, 0], base[0] + 6.5 * nrm[k, 0]],
  254. [base[1] - 6.5 * nrm[k, 1], base[1] + 6.5 * nrm[k, 1]],
  255. color=RED, lw=3.2, zorder=4)
  256. ax.text(96, 180, 'Тоннель, 205 м',
  257. fontsize=11, color=RED, zorder=6, path_effects=[pe.withStroke(linewidth=2.6, foreground='white')])
  258. # пикеты: each 100 м, подпись each 2
  259. pk = 0
  260. while pk * 100.0 <= s_m[-1]:
  261. s = pk * 100.0
  262. k = np.searchsorted(s_m, s)
  263. if k >= len(s_m) - 1:
  264. break
  265. base = align[k]
  266. tang = align[min(k + 2, len(s_m) - 1)] - align[max(k - 2, 0)]
  267. tang = tang / (np.hypot(*tang) + 1e-9)
  268. nr = np.array([-tang[1], tang[0]]) # вправо по ходу (y вниз)
  269. L = 7.0 if pk % 2 == 0 else 4.5
  270. ax.plot([base[0], base[0] + L * nr[0]], [base[1], base[1] + L * nr[1]],
  271. color=RED, lw=1.4, zorder=4)
  272. if pk % 2 == 0:
  273. ax.text(base[0] + (L + 2.5) * nr[0], base[1] + (L + 2.5) * nr[1], str(pk),
  274. fontsize=9.5, color=RED, ha='center', va='center', zorder=5,
  275. path_effects=[pe.withStroke(linewidth=2.2, foreground='white')])
  276. pk += 1
  277. # километровые знаки
  278. for km, s in ((1, 1000.0),):
  279. k = np.searchsorted(s_m, s)
  280. base = align[k]
  281. tang = align[min(k + 2, len(s_m) - 1)] - align[max(k - 2, 0)]
  282. tang /= np.hypot(*tang)
  283. nr = np.array([tang[1], -tang[0]]) # влево по ходу
  284. c = base + 20 * nr
  285. ax.add_patch(Circle(c, 8.5, fill=True, fc='white', ec=RED, lw=1.3, zorder=5))
  286. ax.text(c[0], c[1], str(km), fontsize=11, color=RED, ha='center', va='center', zorder=6)
  287. # пункты А и Б
  288. for pt, name, dx, dy in ((np.array(VUS[0][0]), 'А', 10, 14), (np.array(VUS[-1][0]), 'Б', 9, -2)):
  289. ax.plot(pt[0], pt[1], marker='o', ms=5, color='black', zorder=6)
  290. ax.text(pt[0] + dx, pt[1] + dy, name, fontsize=15, weight='normal', color='black', zorder=6,
  291. path_effects=[pe.withStroke(linewidth=2.4, foreground='white')])
  292. # вершины углов поворота
  293. for c in curves:
  294. ax.plot(c['V'][0], c['V'][1], marker='+', ms=7, color=RED, mew=1.1, zorder=5)
  295. ax.text(c['V'][0] + 5, c['V'][1] - 5, f"ВУ{c['i']}", fontsize=8.5, color=RED, zorder=6,
  296. path_effects=[pe.withStroke(linewidth=2.2, foreground='white')])
  297. # выноски элементов кривых
  298. def fmt_deg(x):
  299. dd = int(x); mm = int(round((x - dd) * 60))
  300. return f"{dd}°{mm:02d}'" if mm else f'{dd}°'
  301. slots = {1: (240, 470), 2: (300, 290), 3: (140, 210)}
  302. for c in curves:
  303. tx, ty = slots[c['i']]
  304. mid = c['A'] * 0.35 + c['B'] * 0.65
  305. # начало линии под рамкой выноски: конец скрывается белой подложкой бокса
  306. ax.annotate('', xy=(mid[0], mid[1]), xytext=(tx + 60, ty + 6),
  307. arrowprops=dict(arrowstyle='-', color=RED, lw=0.7, shrinkA=0, shrinkB=0))
  308. ax.text(tx, ty, f"Кривая {c['i']}: У = {fmt_deg(c['d'])}; R = {c['R_m']:.0f} м\n"
  309. f"Т = {c['T_m']:.1f} м; К = {c['K_m']:.1f} м".replace('.', ','),
  310. fontsize=9.5, color=RED, va='top', zorder=6,
  311. bbox=dict(boxstyle='square,pad=0.25', fc='white', ec='0.6', lw=0.5, alpha=0.92))
  312. # стрелка севера
  313. nx, ny = W - 34, 34
  314. ax.annotate('', xy=(nx, ny - 26), xytext=(nx, ny + 8),
  315. arrowprops=dict(arrowstyle='-|>', color='black', lw=1.4, mutation_scale=16))
  316. ax.text(nx, ny + 20, 'С', fontsize=12, ha='center', weight='normal')
  317. # заголовок и подписи под рамкой
  318. ax.text((W + ML + MR) / 2 - ML, -34, 'ПЛАН ТРАССЫ АВТОМОБИЛЬНОЙ ДОРОГИ А–Б',
  319. fontsize=15.5, weight='normal', ha='center', va='center')
  320. ax.text((W + ML + MR) / 2 - ML, -13, '(трассирование по плану в горизонталях, вариант 6)',
  321. fontsize=11, ha='center', va='center', color='0.25')
  322. # условные обозначения (двумя рядами) и масштаб (справа)
  323. ly = H_PX + 26
  324. ax.text(6, ly - 16, 'Условные обозначения:', fontsize=10.5, weight='normal')
  325. ax.text(W - 4, ly - 16, 'Масштаб 1:10 000', fontsize=11.5, ha='right', weight='normal')
  326. # ряд 1
  327. ax.plot([10, 52], [ly + 2, ly + 2], color=RED, lw=2.0)
  328. ax.text(58, ly + 2, 'трасса (принятый вариант)', fontsize=10, va='center')
  329. ax.plot([268, 298], [ly - 2, ly - 2], color=RED, lw=1.1)
  330. ax.plot([268, 298], [ly + 4, ly + 4], color=RED, lw=1.1)
  331. ax.text(304, ly + 2, 'тоннель', fontsize=10, va='center')
  332. ax.plot([392, 392], [ly - 4, ly + 6], color=RED, lw=1.4)
  333. ax.text(399, ly + 2, 'пикет', fontsize=10, va='center')
  334. ax.add_patch(Circle((475, ly + 2), 7, fc='white', ec=RED, lw=1.2))
  335. ax.text(475, ly + 2, '1', fontsize=9.5, color=RED, ha='center', va='center')
  336. ax.text(487, ly + 2, 'километровый знак', fontsize=10, va='center')
  337. # ряд 2
  338. ax.plot([10, 60], [ly + 26, ly + 26], color='black', lw=0.75)
  339. ax.text(66, ly + 26, 'горизонталь и её отметка, м', fontsize=10, va='center')
  340. ax.text(W - 4, ly + 26, 'горизонтали проведены через 10 м; километровая сетка через 500 м; пикеты через 100 м',
  341. fontsize=10, ha='right')
  342. fig.savefig('assets/images/laba2-plan-trassy.png', dpi=200, facecolor='white')
  343. print('сохранено: assets/images/laba2-plan-trassy.png')