Niall’s virtual diary archives – Friday 3 July 2026

by . Last updated .

Word count: 6185. Estimated reading time: 30 minutes.
Summary:
The installation of the third round of groundworks is documented. Extended hours were spent on site, during which a significant underground cave was encountered and bridged. All foulwater pipes and inspection chambers were installed, pressure tested, and levels were accurately recorded. The project’s substantial costs are also detailed herein.
Friday 3 July 2026:
21:28.
Word count: 6185. Estimated reading time: 30 minutes.
Summary:
The installation of the third round of groundworks is documented. Extended hours were spent on site, during which a significant underground cave was encountered and bridged. All foulwater pipes and inspection chambers were installed, pressure tested, and levels were accurately recorded. The project’s substantial costs are also detailed herein.
It’s been a long three weeks! For those past three weeks I have been most days waking weekday mornings at 4am to be onsite for 6am as that’s when popups installation for my house build starts each day. Work continues until about 4.30pm when the workers leave, then I do cleanup and survey using my camera and drone, usually getting home some time between 6pm and 7pm. I then merge what was built onto my as-built diagram which I’ll be showing you later. I generally then run through the architect’s and engineer’s plans comparing them to what was actually built and trying to identify issues beforehand and/or rectify issues found. You try to go to bed for 9pm but usually don’t make it until 9.30pm. You then rinse and repeat each day.

Most of each day I press on with chores around the site or on this laptop (e.g. writing this very post, which I began writing about two weeks ago!), though this past week I’ve had the kids onsite as their school has ended for the summer which has severely reduced my productivity. Several times a day I get called upon to take decisions, do layout or interpret engineer or architect drawings. Most of the decisions are easy, they just need to be explained so everybody is on the same page, but some are genuinely hard with no obvious answer. I, as the eventual home owner, the buck stops with me so you take your best guess and we plough on.

In case you’re wondering if the engineer or architect should take those decisions instead, the problem is they won’t be able to answer for hours to days, during which no works would continue. That’s not feasible, so to keep things progressing you take your best guess, email your decisions to the architect and engineer, and if they dislike your choice enough hopefully they’ll let you know while it can still be fixed.

All this, plus the seventeen to eighteen hour days, is unsurprisingly very draining, though thankfully it’s been only for a maximum of four days per week so I’ve been able to recuperate in between these bouts of stress. This is the price of self building something atypical – the groundworks guys have been doing groundworks for over forty years, but they’ve never done ones like this before. They tell me they’ve greatly enjoyed themselves, rinse and repeat standard stuff is boring, and this job was definitely not boring, though they did find the pace frustratingly slow at times due to all the measuring and exactness. All this detail has also cost me dearly financially speaking, and I’ll tell you the total cumulative cost of all groundworks done towards the end of this diary entry.

The Plan

Before recounting the story of these popups installation, the previous two rounds of groundworks were as follows:

  1. September 2022: Installation of services: mains electricity, mains telecoms, mains water and mains foulwater along with small gravelled section for shipping container secure storage and site office.

  2. April 2024: Installation of subsoil heat exchanger, removal of lots of soil, addition of dozens of tonnes of T2 structural gravel and permeable membrane. Due to all the soil removal and very expensive gravel added, this was an expensive round – though these past three weeks will probably cost about the same.

The original plan for this third round of groundworks (popups installation) was:

Total works this round:

  • Nine foulwater popups.
  • Seven inspection chambers.
  • Four ESB ducts.
  • Three stormwater popups.
  • Three cavities.
  • Two radon sumps.
  • One mains water.
  • Hundreds of meters of pipe/duct to connect between everything.

And this is it finished:

The Roof

The lads very kindly let me add the roof to their materials order and I went and visited all the building suppliers to get quotes to drive the price down to minimum, which was only possible thanks to it being a large order. Here are the three thousand or so tiles for the roof, with the fascia and soffit actually being behind the tiles next to the wall, but you can’t see that in this photo:

I reckon I saved myself about one third of the materials cost by doing this. Labour is by far the biggest cost in a build, so the effect on the overall cost is much less than one third, but it’s still a very nice saving. Unfortunately we ended up with a lot of hassle from this as the building supplier chosen was the cheapest for a reason – wrong items were supplied, items were missing, one of their trucks got stuck and I had to go find a local tractor to pull it out, the list went on in terms of inconvenience, hassle and stress.

But I did save hundreds of euro, so I guess my time and lack of sleep was worth it.

The Cave

Last round of groundworks we unfortunately hadn’t taken the depth of where the mains services enter the property, and therefore we couldn’t calculate the falls for any of the popups. So the very first item to do was to dig a hole at the front where the services enter, and get a measuring tape on them.

I was actually standing in that hole holding the mains water pipe out of the way of the digger’s arm when everybody panicked and told me to get out of the hole ASAP. It turns out I was quite literally standing over an underground cave, and the digger had just exposed an entrance exactly below where I had been standing:

The top of this underground cavity was about two metres down. It turned out to be four metres long, two meters wide, and about one metre high. It is almost exactly beneath the public footpath outside my driveway, and this is how it looks inside:

As much as it was surprising that we hadn’t discovered this during the last round of groundworks (we probably didn’t dig deep enough, and just missed it), underground cavities are known around this area: an underground river passes deep beneath this locality, and as it carries material away it undermines everything above it over time. We know that this footpath was built around twenty years ago, therefore around one metre of material is removed every twenty years. That meant that my driveway was likely to collapse before I would be dead, which meant I was going to have to spend a bunch of money I hadn’t intended. The solution was a reinforced concrete slab to bridge between the good bits of land so as the underground cave continues to deepen over time, my driveway should get held up until I’m dead (the public footpath and public driveway outside my property are my local government’s problem, not mine). Here you can see we backfilled part of the cave at the right, enough to pour concrete on top, then double layered steel mesh to form the slab:

We then filled the whole base with three cubic metres of concrete, which is oddly satisfying to watch getting poured:

We then dropped onto the reinforced concrete slab a heavy duty reinforced concrete manhole riser of the type they use for high traffic roads: this should stabilise the driveway above by taking weight from above and landing it evenly onto the concrete slab, so rather than weight pushing sideways it should push downwards:

Once that excitement was over, we now could calculate the levels for all the popups: anything taking a toilet flush was to have 1:80 fall, anything not taking a toilet flush was to have 1:40 fall. We’ll get back to that later, but here are both sewer lines being tested by my children pouring water into the inspection chambers and I in the big sewer making sure that the water flows:

Before anyone emails to mention that one side is unfinished and that will be a problem down the line, that side hasn’t been pointed yet. I may do that work myself as I need to mix a batch of cement anyway to patch up holes in the western wall before we paint it.

Land drains and cavities

We installed three land drains around the bottom of the edge of the T2 stone layer: one around the top of the tree cavity, one at the far left as once the house is up you won’t get a digger down there, and one around the bottom of the pool cavity:

Land drains are mainly there to prevent the build up of water which if it turned to ice, it would cause the T2 stone layer to expand, pushing up the house above. By digging a small ditch around the T2 stone layer, any water which gets into the T2 stone layer should flow into the ditch, and then the land drain spreads that around so it drains away. Similarly, if water tries to enter the T2 stone layer from outside horizontally, it will fall downwards and hit the land drain rather than enter the T2 stone layer. All this is great, however the land drain pipe is full of holes and not strong – it crushes easily. So you only ever install it last-most possible, which is exactly what we’ve done leaving the coils above ground for continuing installation later.

We dug out three cavities: the pool cavity, the pad cavity, and the tree cavity:

These are the pool and tree cavities: the pool cavity is the largest, but the tree cavity is the deepest:

The pad cavity is just 100 mm deep, but we had to spend enormous care here as most of the house services route around it, and we cannot disturb the subsoil underneath this as this pad takes the most weight of any pad in the house. So much weight in fact we need to triple mesh the concrete pad above it, and two large steel poles bear down onto this pad from above:

The sewer to the left is the master bedroom ensuite toilet, and the sewer to the right is the master bedroom ensuite shower plus upstairs bathroom. Right in between these two where the pad will go there will be his and hers sinks. When I’ll be brushing my teeth to go to bed, the most intense weight from above my head shall be load spread under my feet. Which is food for thought!

In case you are wondering why this pad takes the most weight anywhere in the house, it is because the rainwater harvesting tanks are above. When full, these add twenty metric tonnes to the rest of the weight above which includes the outer block leaf upstairs – those sit on metal girders which then puts as much load onto the ground floor outer block leaf as possible, but for the single corner where there is no other support that’s what the two steel poles and this pad support.

After each work day I had the drone take an aerial photo from thirty, forty and fifty metres up. I tried to find free of cost software to turn the thirty metre height photos into a timelapse video, but I couldn’t find any, so I had Step 3.7 Flash write a utility program which takes in photos, analyses them, chooses the best reference photo, and then scales-rotates-crops all the others to match. It also adjusts brightness so everything is roughly the same brightness over time, then emits a video in your choice of AV1, h.265 or h.264. Step 3.7 took about two hours to complete this task for a total cost of about US$0.66, and I only had to re-steer it once. The results are acceptable:

Here is the code it wrote: not bad methinks, I probably wouldn’t have done much better myself and furthermore I was and am not familiar with OpenCV for image analysis and processing, whereas it knew what it was doing relative to me:

#!/usr/bin/env python3
"""make_timelapse.py β€” Aligned AV1/H.264/H.265 timelapse from DJI aerial JPEGs."""

from __future__ import annotations

import argparse
import math
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple

import cv2
import numpy as np


# ── Linearization (sRGB inverse EOTF) ─────────────────────────────────────


def srgb_to_linear(val: np.ndarray) -> np.ndarray:
    val = val.astype(np.float32) / 255.0
    lo = val <= 0.04045
    hi = ~lo
    out = np.empty_like(val, dtype=np.float32)
    out[lo] = val[lo] / 12.92
    out[hi] = ((val[hi] + 0.055) / 1.055) ** 2.4
    return out


def linear_to_srgb(val: np.ndarray) -> np.ndarray:
    lo = val <= 0.0031308
    hi = ~lo
    out = np.empty_like(val, dtype=np.float32)
    out[lo] = 12.92 * val[lo]
    out[hi] = 1.055 * (val[hi] ** (1.0 / 2.4)) - 0.055
    return np.clip(out * 255.0, 0, 255).astype(np.uint8)


# ── Data structures ────────────────────────────────────────────────────────


class FrameResult(NamedTuple):
    filename: str
    index: int  # 0-based source index
    M: np.ndarray | None  # 2x3 similarity matrix (src→ref), None if skipped
    inliers: int  # RANSAC inlier count
    total_matches: int
    skipped: bool
    skip_reason: str
    warp_bgr: np.ndarray | None  # uint8 BGR after warp
    valid_rect: tuple[int, int, int, int] | None  # (x0, y0, x1, y1) in ref space
    crop_rect: tuple[int, int, int, int] | None  # same


class TimelapseState:
    def __init__(self) -> None:
        self.last_output_bgr: np.ndarray | None = None


# ── ORB matching (uint8 BGR grayscale) ─────────────────────────────────────


def match_to_reference(
    ref_gray: np.ndarray,
    curr_gray: np.ndarray,
    fringe_mask: np.ndarray | None,
) -> tuple[np.ndarray | None, int, int]:
    """Return (M, inliers, total_good_matches). M=None if insufficient matches."""
    orb = cv2.ORB_create(nfeatures=2000, fastThreshold=5)
    bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)

    kp_ref, des_ref = orb.detectAndCompute(ref_gray, None)
    kp_curr, des_curr = orb.detectAndCompute(curr_gray, None)
    if des_ref is None or des_curr is None or len(kp_ref) < 4 or len(kp_curr) < 4:
        return None, 0, 0

    raw_matches = bf.knnMatch(des_ref, des_curr, k=2)
    good = []
    for pair in raw_matches:
        if len(pair) == 2:
            m, n = pair
            if m.distance < 0.75 * n.distance:
                good.append(m)

    if len(good) < 4:
        return None, len(good), len(good)

    src_pts = np.float32([kp_curr[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
    dst_pts = np.float32([kp_ref[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)

    if fringe_mask is not None:
        h, w = curr_gray.shape
        y0, y1 = int(0.30 * h), int(0.70 * h)
        x0, x1 = int(0.30 * w), int(0.70 * w)

        def in_fringe(pts: np.ndarray) -> np.ndarray:
            x = pts[:, 0, 0]
            y = pts[:, 0, 1]
            return ~((y0 <= y) & (y < y1) & (x0 <= x) & (x < x1))

        fringe_src = in_fringe(src_pts)
        fringe_dst = in_fringe(dst_pts)
        keep = fringe_src & fringe_dst
        src_pts = src_pts[keep]
        dst_pts = dst_pts[keep]

        if src_pts.shape[0] < 4:
            return None, 0, len(good)

    M, inlier_mask = cv2.estimateAffinePartial2D(
        src_pts,
        dst_pts,
        method=cv2.RANSAC,
        ransacReprojThreshold=1.0,
        maxIters=2000,
        confidence=0.99,
        refineIters=10,
    )
    if M is None:
        return None, src_pts.shape[0], len(good)
    inliers = int(inlier_mask.sum()) if inlier_mask is not None else src_pts.shape[0]
    return M, inliers, len(good)


def build_fringe_mask(h: int, w: int) -> np.ndarray | None:
    y0, y1 = int(0.30 * h), int(0.70 * h)
    x0, x1 = int(0.30 * w), int(0.70 * w)
    edge_pixels = (
        y0 * w
        + (h - y1) * w
        + y1 * x0
        + y1 * (w - x1)
    )
    if edge_pixels < 4000:
        return None
    mask = np.zeros((h, w), dtype=np.uint8)
    mask[:y0, :] = 255
    mask[y1:, :] = 255
    mask[y0:y1, :x0] = 255
    mask[y0:y1, x1:] = 255
    return mask


# ── Transform validation ────────────────────────────────────────────────────


def decompose_similarity(M: np.ndarray) -> tuple[float, float, float, float]:
    tx = float(M[0, 2])
    ty = float(M[1, 2])
    s = float(np.sqrt(M[0, 0] ** 2 + M[1, 0] ** 2))
    theta = float(np.degrees(np.arctan2(M[1, 0], M[0, 0])))
    return s, theta, tx, ty


def validate_transform(M: np.ndarray) -> str | None:
    s, theta, _, _ = decompose_similarity(M)
    if not (0.90 <= s <= 1.10):
        return f"scale={s:.4f} outside [0.90, 1.10]"
    if abs(theta) > 5.0:
        return f"rotation={theta:.2f}deg outside Β±5Β°"
    return None


# ── Pass 0: pairwise scoring ────────────────────────────────────────────────


def score_pair(ref_gray: np.ndarray, curr_gray: np.ndarray, fringe_mask: np.ndarray | None) -> float:
    M, inliers, total = match_to_reference(ref_gray, curr_gray, fringe_mask)
    if M is None:
        return float("inf")
    cap_err = validate_transform(M)
    if cap_err:
        return float("inf")
    if inliers < 4:
        return float("inf")
    return 1.0 / (inliers + 1)


def _score_row(
    r: int,
    n: int,
    greys: list[np.ndarray | None],
    fringe_masks: list[np.ndarray | None],
) -> float:
    if greys[r] is None:
        return float("inf")
    total = 0.0
    for j in range(n):
        if j == r or greys[j] is None:
            continue
        total += score_pair(greys[r], greys[j], fringe_masks[j])
    return total


def select_reference_frame(
    files: list[str],
    greys: list[np.ndarray | None],
    fringe_masks: list[np.ndarray | None],
) -> int:
    print("Pass 0: pairwise scoring to select reference frame...", file=sys.stderr)

    n = len(files)
    total_scores: list[float] = [0.0] * n

    workers = min(os.cpu_count() or 1, n)
    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {
            executor.submit(_score_row, r, n, greys, fringe_masks): r
            for r in range(n)
        }
        for future in futures:
            r = futures[future]
            total_scores[r] = future.result()

    ref_idx = int(np.argmin(total_scores))
    for i, sc in enumerate(total_scores):
        print(f"  {files[i]}: total_score={sc:.4f}", file=sys.stderr)
    print(f"Selected reference frame: {files[ref_idx]} (index {ref_idx})", file=sys.stderr)
    return ref_idx


# ── Valid-content rect via mask warp ───────────────────────────────────────


def valid_content_rect(M: np.ndarray, h_src: int, w_src: int, h_ref: int, w_ref: int) -> tuple[int, int, int, int]:
    mask_src = np.ones((h_src, w_src), dtype=np.uint8) * 255
    warped_mask = cv2.warpAffine(
        mask_src, M, (w_ref, h_ref), borderMode=cv2.BORDER_CONSTANT, borderValue=0
    )
    ys, xs = np.where(warped_mask > 0)
    if len(xs) == 0:
        return (0, 0, 0, 0)
    return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)


# ── Peripheral annulus mask ────────────────────────────────────────────────


def peripheral_mask(h: int, w: int) -> np.ndarray:
    y_top = int(math.ceil(0.10 * h))
    y_bot = int(math.floor(0.90 * h))
    x_left = int(math.ceil(0.10 * w))
    x_right = int(math.floor(0.90 * w))
    mask = np.zeros((h, w), dtype=bool)
    mask[:y_top, :] = True
    mask[y_bot:, :] = True
    mask[y_top:y_bot, :x_left] = True
    mask[y_top:y_bot, x_right:] = True
    return mask


# ── Brightness correction ──────────────────────────────────────────────────


def compute_gains(
    ref_linear: np.ndarray, frame_linear: np.ndarray, peri_mask: np.ndarray
) -> np.ndarray:
    gains = np.zeros(3, dtype=np.float32)
    for c in range(3):
        r = ref_linear[:, :, c][peri_mask].astype(np.float32)
        f = frame_linear[:, :, c][peri_mask].astype(np.float32)
        denom = (f ** 2).sum()
        if denom < 1e-12:
            gains[c] = 1.0
        else:
            gains[c] = (r * f).sum() / denom
    return gains


def apply_gains(frame: np.ndarray, gains: np.ndarray) -> np.ndarray:
    return frame * gains.astype(np.float32)


# ── Encoding ───────────────────────────────────────────────────────────────

AV1_ARGS = [
    "-c:v", "libsvtav1",
    "-crf", "18",
    "-preset", "5",
    "-pix_fmt", "yuv420p",
    "-color_primaries", "bt709",
    "-color_trc", "bt709",
    "-colorspace", "bt709",
    "-movflags", "+faststart",
]
H264_ARGS = [
    "-c:v", "libx264",
    "-crf", "15",
    "-pix_fmt", "yuv420p",
    "-color_primaries", "bt709",
    "-color_trc", "bt709",
    "-colorspace", "bt709",
]
H265_ARGS = [
    "-c:v", "libx265",
    "-crf", "16",
    "-pix_fmt", "yuv420p",
    "-color_primaries", "bt709",
    "-color_trc", "bt709",
    "-colorspace", "bt709",
]
CODEC_MAP = {"av1": AV1_ARGS, "h264": H264_ARGS, "h265": H265_ARGS}


def check_ffmpeg() -> None:
    try:
        subprocess.run(
            ["ffmpeg", "-version"],
            capture_output=True,
            check=True,
        )
    except (FileNotFoundError, subprocess.CalledProcessError):
        print(
            "ERROR: ffmpeg not found. Install with: brew install ffmpeg",
            file=sys.stderr,
        )
        sys.exit(1)


def check_av1_encoder(requested_av1: bool) -> str | None:
    try:
        result = subprocess.run(
            ["ffmpeg", "-encoders"],
            capture_output=True,
            text=True,
            check=True,
        )
    except (FileNotFoundError, subprocess.CalledProcessError):
        return None
    encoders = result.stdout.lower()
    if "libsvtav1" in encoders:
        return "libsvtav1"
    if requested_av1:
        print(
            "WARNING: libsvtav1 not found in ffmpeg build. "
            "Attempting libaom-av1 fallback (slower).",
            file=sys.stderr,
        )
        if "libaom-av1" in encoders:
            return "libaom-av1"
    return None


def build_ffmpeg_args(
    width: int, height: int, fps: int, output: str, encoder: str | None, codec: str
) -> list[str]:
    if encoder is None:
        print(
            "ERROR: No suitable AV1 encoder found in ffmpeg. "
            "Install with: brew install ffmpeg (includes libsvtav1).",
            file=sys.stderr,
        )
        sys.exit(1)

    args = [
        "ffmpeg",
        "-y",
        "-f", "rawvideo",
        "-pix_fmt", "bgr24",
        "-s", f"{width}x{height}",
        "-r", str(fps),
        "-i", "-",
    ]

    if codec == "av1":
        if encoder == "libsvtav1":
            args.extend(AV1_ARGS)
        else:
            args.extend(["-c:v", encoder, "-crf", "30", "-cpu-used", "4", "-pix_fmt", "yuv420p",
                         "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"])
    else:
        args.extend(CODEC_MAP[codec])

    args.append(output)
    return args


def run_encoder(
    frames_iter, width: int, height: int, fps: int, output: str, encoder: str | None, codec: str
) -> bool:
    args = build_ffmpeg_args(width, height, fps, output, encoder, codec)
    proc = subprocess.Popen(
        args,
        stdin=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    ok = True
    try:
        for bgr in frames_iter:
            try:
                proc.stdin.write(bgr.tobytes())
            except BrokenPipeError:
                stderr_text = proc.stderr.read().decode("utf-8", errors="replace")
                print(f"ERROR: ffmpeg pipe broken. stderr:\n{stderr_text}", file=sys.stderr)
                ok = False
                break
    finally:
        if proc.stdin and not proc.stdin.closed:
            proc.stdin.close()
        stderr_text = b""
        if proc.stderr:
            stderr_text = proc.stderr.read()
        ret = proc.wait()
        if ret != 0:
            print(
                f"ERROR: ffmpeg exited with code {ret}.\n"
                + stderr_text.decode("utf-8", errors="replace"),
                file=sys.stderr,
            )
            ok = False
    return ok


# ── Main pipeline ─────────────────────────────────────────────────────────


def process_sequence(
    input_dir: str,
    output: str,
    frames_per_image: int,
    fps: int,
    codec: str,
    encoder_choice: str | None = None,
    scale_factor: float | None = None,
) -> None:
    check_ffmpeg()
    requested_av1 = codec == "av1"
    encoder = encoder_choice if encoder_choice else check_av1_encoder(requested_av1)

    exts = {".jpg", ".jpeg", ".JPG", ".JPEG"}
    files = sorted(
        f for f in os.listdir(input_dir)
        if os.path.splitext(f)[1] in exts
    )
    if not files:
        print(f"ERROR: No JPEG files found in {input_dir}", file=sys.stderr)
        sys.exit(1)

    print(f"Found {len(files)} images.", file=sys.stderr)

    # Load all frames (needed for Pass 0 pairwise scoring)
    all_bgrs: list[np.ndarray | None] = []
    all_grays: list[np.ndarray | None] = []
    all_fringe: list[np.ndarray | None] = []
    h_ref, w_ref = 0, 0
    for fname in files:
        path = os.path.join(input_dir, fname)
        bgr = cv2.imread(path)
        if bgr is None:
            print(f"WARNING: Cannot load {fname}", file=sys.stderr)
            all_bgrs.append(None)
            all_grays.append(None)
            all_fringe.append(None)
            continue
        h, w = bgr.shape[:2]
        if h_ref == 0:
            h_ref, w_ref = h, w
        all_bgrs.append(bgr)
        all_grays.append(cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY))
        all_fringe.append(build_fringe_mask(h, w))

    if h_ref == 0:
        print("ERROR: Could not load any valid images.", file=sys.stderr)
        sys.exit(1)

    # ── Pass 0: select reference frame ─────────────────────────────────────
    ref_idx = select_reference_frame(files, all_grays, all_fringe)
    ref_bgr = all_bgrs[ref_idx]
    ref_gray = all_grays[ref_idx]
    assert ref_bgr is not None and ref_gray is not None

    # ── Pass 1: compute transforms to chosen reference ──────────────────────
    results: list[FrameResult] = []
    valid_rects: list[tuple[int, int, int, int]] = []

    for idx, fname in enumerate(files):
        src_bgr = all_bgrs[idx]
        curr_gray = all_grays[idx]

        if idx == ref_idx:
            full_rect = (0, 0, w_ref, h_ref)
            results.append(
                FrameResult(
                    filename=fname,
                    index=idx,
                    M=np.eye(2, 3, dtype=np.float32),
                    inliers=0,
                    total_matches=0,
                    skipped=False,
                    skip_reason="",
                    warp_bgr=ref_bgr,
                    valid_rect=full_rect,
                    crop_rect=None,
                )
            )
            valid_rects.append(full_rect)
            continue

        if src_bgr is None or curr_gray is None:
            msg = f"corrupt JPEG"
            print(f"WARNING: Skipping {fname}: {msg}", file=sys.stderr)
            results.append(
                FrameResult(
                    filename=fname,
                    index=idx,
                    M=None,
                    inliers=0,
                    total_matches=0,
                    skipped=True,
                    skip_reason=msg,
                    warp_bgr=None,
                    valid_rect=None,
                    crop_rect=None,
                )
            )
            valid_rects.append((0, 0, 0, 0))
            continue

        h_src, w_src = src_bgr.shape[:2]

        # Try fringe mask then full frame against chosen reference
        fringe_mask = build_fringe_mask(h_src, w_src)
        use_fringe = fringe_mask is not None
        M, inliers, total = match_to_reference(ref_gray, curr_gray, fringe_mask)

        if M is None and use_fringe:
            M, inliers, total = match_to_reference(ref_gray, curr_gray, None)

        skipped = False
        skip_reason = ""
        if M is None:
            skipped = True
            skip_reason = f"too few matches (got {total} good, 0 inliers)"
            print(
                f"WARNING: Skipping {fname}: {skip_reason}",
                file=sys.stderr,
            )
        else:
            cap_err = validate_transform(M)
            if cap_err:
                skipped = True
                skip_reason = f"transform cap exceeded: {cap_err}"
                print(
                    f"WARNING: Skipping {fname}: {skip_reason}",
                    file=sys.stderr,
                )
            else:
                if inliers < 6:
                    skipped = True
                    skip_reason = f"only {inliers} inliers (minimum 6 required)"
                    print(
                        f"WARNING: Skipping {fname}: {skip_reason}",
                        file=sys.stderr,
                    )
                elif inliers < 15:
                    print(
                        f"WARNING: {fname}: low inlier count {inliers} "
                        "(model may be unreliable)",
                        file=sys.stderr,
                    )

            # Warn (do not reject) on large absolute translation vs reference
            if not skipped and M is not None:
                _, _, tx, ty = decompose_similarity(M)
                if abs(tx) > 500.0 or abs(ty) > 500.0:
                    print(
                        f"WARNING: Frame {idx} ({fname}): large translation "
                        f"({tx:.1f}, {ty:.1f})px from reference β€” verify alignment",
                        file=sys.stderr,
                    )

        if skipped:
            results.append(
                FrameResult(
                    filename=fname,
                    index=idx,
                    M=None,
                    inliers=inliers,
                    total_matches=total,
                    skipped=True,
                    skip_reason=skip_reason,
                    warp_bgr=None,
                    valid_rect=None,
                    crop_rect=None,
                )
            )
            valid_rects.append((0, 0, 0, 0))
            continue

        # Warp to reference space
        warped = cv2.warpAffine(
            src_bgr, M, (w_ref, h_ref), borderMode=cv2.BORDER_REPLICATE
        )

        vcr = valid_content_rect(M, h_src, w_src, h_ref, w_ref)

        results.append(
            FrameResult(
                filename=fname,
                index=idx,
                M=M,
                inliers=inliers,
                total_matches=total,
                skipped=False,
                skip_reason="",
                warp_bgr=warped,
                valid_rect=vcr,
                crop_rect=None,
            )
        )
        valid_rects.append(vcr)

    # ── Compute final crop rect ─────────────────────────────────────────────
    x0 = max(r[0] for r in valid_rects)
    y0 = max(r[1] for r in valid_rects)
    x1 = min(r[2] for r in valid_rects)
    y1 = min(r[3] for r in valid_rects)

    crop_w = x1 - x0
    crop_h = y1 - y0

    pad_w = (2 - crop_w % 2) % 2
    pad_h = (2 - crop_h % 2) % 2
    x1 += pad_w
    y1 += pad_h
    crop_w += pad_w
    crop_h += pad_h

    if crop_w <= 0 or crop_h <= 0:
        print(
            f"ERROR: Crop dimensions are zero or negative ({crop_w}x{crop_h}). "
            f"Valid-content rects: {valid_rects}",
            file=sys.stderr,
        )
        sys.exit(1)

    ref_area = w_ref * h_ref
    crop_area = crop_w * crop_h
    if crop_area < 0.5 * ref_area:
        print(
            f"ERROR: Crop area ({crop_area} px = {crop_area/ref_area*100:.1f}% of reference) "
            f"is below 50% guard. Aborting.",
            file=sys.stderr,
        )
        sys.exit(1)

    print(
        f"Crop rect: x={x0}..{x1}, y={y0}..{y1}, size={crop_w}x{crop_h} "
        f"({crop_area/ref_area*100:.1f}% of reference)",
        file=sys.stderr,
    )

    for i, res in enumerate(results):
        if res.valid_rect is not None and not res.skipped:
            x0f, y0f, x1f, y1f = res.valid_rect
            cc = (
                max(x0f, x0),
                max(y0f, y0),
                min(x1f, x1),
                min(y1f, y1),
            )
            old = results[i]
            results[i] = old._replace(crop_rect=cc)

    # ── Pass 2: crop, brightness correct, generate output frames ─────────────
    state = TimelapseState()
    peri = peripheral_mask(crop_h, crop_w)

    out_w = crop_w if scale_factor is None else max(2, int(round(crop_w * scale_factor / 2)) * 2)
    out_h = crop_h if scale_factor is None else max(2, int(round(crop_h * scale_factor / 2)) * 2)

    ref_crop_bgr = results[ref_idx].warp_bgr[y0:y1, x0:x1]
    ref_linear = srgb_to_linear(ref_crop_bgr.astype(np.float32))

    def gen_output_frames():
        last_output = ref_crop_bgr
        for res in results:
            if res.skipped or res.warp_bgr is None:
                if state.last_output_bgr is None:
                    last_output = ref_crop_bgr
                    state.last_output_bgr = ref_crop_bgr
                out = (
                    state.last_output_bgr
                    if scale_factor is None
                    else cv2.resize(
                        state.last_output_bgr, (out_w, out_h), interpolation=cv2.INTER_AREA
                    )
                )
                for _ in range(frames_per_image):
                    yield out
                print(
                    f"WARNING: Frame {res.index} ({res.filename}): {res.skip_reason}. "
                    f"Substituting previous frame.",
                    file=sys.stderr,
                )
                continue

            crop_bgr = res.warp_bgr[y0:y1, x0:x1]

            if res.index == ref_idx:
                corrected_bgr = crop_bgr
            else:
                frame_linear = srgb_to_linear(crop_bgr.astype(np.float32))
                gains = compute_gains(ref_linear, frame_linear, peri)
                extreme = ((gains > 1.5) | (gains < 0.5)).any()
                if extreme:
                    print(
                        f"WARNING: Frame {res.index} ({res.filename}): "
                        f"extreme gains {gains.tolist()} β€” check exposure",
                        file=sys.stderr,
                    )
                corrected_linear = apply_gains(frame_linear, gains)
                corrected_bgr = linear_to_srgb(corrected_linear)

            clipped = np.count_nonzero(
                (corrected_bgr == 0) | (corrected_bgr == 255)
            )
            total_px = corrected_bgr.size
            if total_px > 0 and clipped > 0.01 * total_px:
                pct = clipped / total_px * 100
                print(
                    f"WARNING: Frame {res.index} ({res.filename}): "
                    f"{pct:.2f}% pixels clipped after correction",
                    file=sys.stderr,
                )

            state.last_output_bgr = corrected_bgr
            last_output = corrected_bgr

            out = (
                corrected_bgr
                if scale_factor is None
                else cv2.resize(
                    corrected_bgr, (out_w, out_h), interpolation=cv2.INTER_AREA
                )
            )
            for _ in range(frames_per_image):
                yield out

    ok = run_encoder(
        gen_output_frames(),
        width=out_w,
        height=out_h,
        fps=fps,
        output=output,
        encoder=encoder,
        codec=codec,
    )
    if ok:
        print(f"Done: {output} ({len(files)} sources Γ— {frames_per_image} frames each)", file=sys.stderr)
    else:
        print(f"ERROR: Encoding failed. Output may be incomplete: {output}", file=sys.stderr)
        sys.exit(1)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Generate an aligned AV1/H.264/H.265 timelapse from DJI aerial JPEGs."
    )
    parser.add_argument("--input", default="./input", help="Input JPEG directory")
    parser.add_argument("--output", default="./timelapse.mp4", help="Output video path")
    parser.add_argument(
        "--frames-per-image",
        type=int,
        default=30,
        help="Output frames per source image (default: 30)",
    )
    parser.add_argument(
        "--codec",
        choices=["av1", "h264", "h265"],
        default="av1",
        help="Output video codec (default: av1)",
    )
    parser.add_argument(
        "--fps",
        type=int,
        default=30,
        help="Output frame rate (default: 30)",
    )
    parser.add_argument(
        "--scale-factor",
        type=float,
        default=None,
        help="Output resolution as a fraction of input dimensions (default: no scaling)",
    )
    args = parser.parse_args()

    scale_factor = args.scale_factor

    if not os.path.isdir(args.input):
        print(f"ERROR: Input directory does not exist: {args.input}", file=sys.stderr)
        sys.exit(1)

    process_sequence(
        input_dir=args.input,
        output=args.output,
        frames_per_image=args.frames_per_image,
        fps=args.fps,
        codec=args.codec,
        scale_factor=scale_factor,
    )


if __name__ == "__main__":
    main()

Pressure testing the subsoil heat exchanger

The lads accidentally snapped the subsoil heat exchanger pipe twice and ‘bruised’ it a third time. So we needed to do a pressure test to make sure that the repairs weren’t leaking:

We plugged the mains water in into the subsoil heat exchanger, let the 150 metres of it fill with water, then let it wash out all the crap that was in there, finally we attached a radiator pressure tester. What this does is pump water in until you reach a desired pressure. You then leave it sit for a few hours and check that the pressure doesn’t drop, which would indicate a leak. We tested it at 100 psi, which is about seven bar. This is a bit much, to be honest, but the groundworks lads still work in imperial so 100 psi felt right to them. As all our fittings were plastic, they were right to use a higher test pressure, but the pipe itself is only rated to twelve bar or so. I personally would have used five bar, I think that enough for plastic fittings test (also it’s less hand pumping to get there and you get less pipe stretching so it’s way faster). Still, glad to know it’s all good at seven bar.

As built

This is my as-built overlay with the current site picture overlaid by the portion of items which were done in round three of groundworks. I personally measured the bottom of each foulwater popup and inspection chamber using my own laser level, plus the levels of each of the T2 stone layers throughout the site. I measured by sticking my tape measure down each popup and then taking where the laser appeared on the tape measure – note that for any popup less than 110 mm, I added 50 mm to the depth for any popup above a 90 degree bend, as my tape measure probably intersected the side of the bend and therefore read the depth too high. I have placed the intended plan picture from above directly below this one, so you can compare them easily.

Comparing these two, the leftmost inspection chambers are lower than planned, though the second down leftmost IC is relatively less lower, and therefore the fall between the topmost left two is half that planned. At the resulting 1:80 fall, we may get some soap and food residue dropping out between those two inspection chambers. The falls within the house to the IC are as planned, 290 mm was planned and 300 mm was achieved. The main bathroom toilet has a fall less than 1:80 which is unfortunate, however as it’s a 90 degree turn it should have higher velocity and I think it’ll be okay – I might stick my inspection camera down there, and see if water is pooling. The leftmost sewer line has a fall of 1:46, steeper than the planned 1:80, and then to the main sewer manhole it is also 1:46, where it should be 1:80.

For the rightmost sewer, the topmost IC is considerably lower than planned: a full 320 mm. The shower sewer has a steep fall to the ensuite toilet sewer of 1:23, then there is an even steeper fall to the IC outside of 1:19. This is obviously far steeper than 1:80. I didn’t lift the cover of the bottom rightmost IC as it’s covered with gravel, but I can say that the total fall from the top rightmost IC to the main sewer manhole is 1:68.

For the upper stormwater, everything is more or less spot on apart from the IC which being so deep was hand made from concrete and a two foot diameter concrete pipe. It’s a few dozen millimetres deeper than it ought to be, but not too bad.

Finally, the drone photo reveals that three popups were installed slightly out of place: the washing machine one is to the top right of where it should be, the small right ESB duct is to the bottom left of where it should be, and the bottom middle ESB duct emerges to the bottom left of where it should be. Of those, the only one which matters is the washing machine one, it’s now within the wall, but I’m sure we can build around that okay.

The soil pile excavated and materials added

As always, one is amazed at how much soil you dig out every time. Digging it out of course ‘fluffs’ the soil, making it take up twice the volume it used to be when in the ground. Still, that’s a fair soil pile, we basically were out of storage space:

There is at least ten truck loads of soil to remove, generally it is twenty metric tonnes per truck, so that is two hundred metric tonnes and that will cost me at least three grand ex VAT thanks to EU rules around soil transport.

Deposited into the ground was two truck loads of pipe grit, which is forty metric tonnes. A further three truck loads of T2 stone were spread around, which is sixty metric tonnes which is approximately 30 m3. So two hundred tonnes was removed and replaced with one hundred tonnes of expensive stone, leaving a net one hundred tonnes removed which are the three cavities shown above, and pipes installed.

In 2024 T2 stone cost €505 ex VAT per truck load. It now costs €600 ex VAT per truck load. I didn’t get a price for the pipe grit, it’ll be a bit cheaper, but certainly a good three grand went out on aggregate this time round. That’s way better than in 2024 where 18k of the then total bill was for material added and removed, but still a fair whack of money: between that and the soil removal, we’re talking six to seven thousand euro on trucks bringing or removing material.

Total cost

Back in the very early days we had €40k as the PC sum for site preparation and groundworks. Here’s what was actually spent:

  • 2022: €6,810 inc VAT for two days of works.
  • 2024: €25,537 inc VAT for five days of works, of which ~€18k was for aggregates and soil removal.
  • 2026: Estimated €30,000 inc VAT for nine days of works, which includes maybe €5k worth of roofing materials and €7k for aggregates and soil removal, so maybe €18k went on labour plus materials and digger & dumper hire. The digger probably cost €550 per week, the dumper €300 per week, so maybe €2.5k went on hiring those and maybe €3k if including diesel. Another €5k might have gone on pipe materials etc, so €10k remains for wages, insurance, profit margin etc. Assuming a €400 per day worker cost including payroll taxes, that leaves about a €3k profit margin. That’s fair enough, I know I wouldn’t do a job for less than a ten percent profit margin.

So, if I remove the roofing materials, that’s a total cost of ~€57,000 for site preparation and groundworks, rather more than anticipated – though, to be fair, cost estimates pre-covid are barely comparable to today’s costs in anything, let alone construction materials.

What’s next?

We need a topographical survey of the as-built site, as my drone and laser level are only accurate within maybe 50 mm and a professional survey should come in within 10 mm.

We then need the builder to get some building onto this site! Tomorrow four years ago we got planning permission for this build. That means exactly ONE YEAR REMAINS until planning permission expires. So people really do need to get a move on now.

The second topmost left IC may be too high to allow toilet flushes to work correctly, if the engineer thinks so then that IC and surrounding pipes will need to be relaid. We’ll see what they say next week.

Finally, I expect next week my children will stain the left boundary wall with a ‘dark oak’ colour – I would call it ‘chocolate’ colour myself, but ‘dark oak’ is what it is called. It should take them four coats and therefore four days to complete: to save money, they’ll be applying three coats of the cheapest possible stain, followed by a fourth topcoat of the expensive stuff. The cheapest possible stain is not actually that terrible, it separates quickly at rest, so it needs a vigorous agitation before every time you use it. After that it is a bit thin, but that also means it dries quickly and it soaks into the wood well. Three coats onto bare wood produces an acceptable stain albeit still a bit blotchy (we know because we tested it on plywood sheets). The fourth coat of expensive stain should eliminate the blotchiness, also the expensive stain has some polyurethane in it so the final finish kinda has a plasticky varnishy feel to it, resembling a kind of matte varnish. I’ve used it before on park benches, you do get at least five years out of it and probably more – it begins to look raggedly after five years, but almost certainly would last at least another three years before peeling. So I believe their tin’s claims that you can expect ten years of wood protection. The cheap stain makes no such claims, it says three to five years depending on rain exposure. Fair enough for the price – it is less than one tenth the price of the expensive stain.

After they stain the left boundary wall, they have various camps for the next two weeks, then I intend to have them paint the right boundary wall in textured masonry paint. That stuff is a pain to work with, but it lasts forever on the wall and produces a very good finish. It is normally hideously expensive, but Screwfix will sell you one tub per shop per two weeks at €50 inc VAT per tub as a loss leader. So, if you need five tubs as we shall need, you laboriously go accumulate them over months which I’ve been doing and I now have all the tubs I think we shall need to complete the job.

In between painting, especially if the weather is nice, I expect to repeat the e-bike jaunts we did last summer around North Cork. It’s such a pretty part of the world when in sunshine. Thankfully, we live right in the middle of it, and I intend to make the best of it for another summer.

#house




Go back to the archive index Go back to the latest entries

Contact the webmaster: Niall Douglas @ webmaster2<at symbol>nedprod.com (Last updated: 2026-07-03 21:28:37 +0000 UTC)